ace-hitl 0.8.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/hitl/config.yml +3 -0
  3. data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +13 -0
  4. data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +13 -0
  5. data/CHANGELOG.md +128 -0
  6. data/README.md +42 -0
  7. data/Rakefile +10 -0
  8. data/docs/demo/ace-hitl-scope-and-answer.tape.yml +37 -0
  9. data/docs/demo/fixtures/demo-context.md +7 -0
  10. data/docs/usage.md +112 -0
  11. data/exe/ace-hitl +18 -0
  12. data/handbook/README.md +14 -0
  13. data/handbook/skills/as-hitl/SKILL.md +29 -0
  14. data/handbook/workflow-instructions/hitl.wf.md +110 -0
  15. data/lib/ace/hitl/atoms/hitl_file_pattern.rb +20 -0
  16. data/lib/ace/hitl/atoms/hitl_id_formatter.rb +15 -0
  17. data/lib/ace/hitl/cli/commands/create.rb +76 -0
  18. data/lib/ace/hitl/cli/commands/list.rb +63 -0
  19. data/lib/ace/hitl/cli/commands/show.rb +79 -0
  20. data/lib/ace/hitl/cli/commands/update.rb +127 -0
  21. data/lib/ace/hitl/cli/commands/wait.rb +74 -0
  22. data/lib/ace/hitl/cli.rb +62 -0
  23. data/lib/ace/hitl/models/hitl_event.rb +32 -0
  24. data/lib/ace/hitl/molecules/hitl_answer_editor.rb +25 -0
  25. data/lib/ace/hitl/molecules/hitl_config_loader.rb +50 -0
  26. data/lib/ace/hitl/molecules/hitl_creator.rb +207 -0
  27. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +53 -0
  28. data/lib/ace/hitl/molecules/hitl_loader.rb +113 -0
  29. data/lib/ace/hitl/molecules/hitl_resolver.rb +30 -0
  30. data/lib/ace/hitl/molecules/hitl_scanner.rb +42 -0
  31. data/lib/ace/hitl/molecules/resume_dispatcher.rb +99 -0
  32. data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +77 -0
  33. data/lib/ace/hitl/organisms/hitl_manager.rb +462 -0
  34. data/lib/ace/hitl/version.rb +7 -0
  35. data/lib/ace/hitl.rb +24 -0
  36. metadata +164 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "../../molecules/hitl_display_formatter"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ class List < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc "List HITL events"
14
+
15
+ option :status, type: :string, aliases: %w[-s], desc: "Filter by status"
16
+ option :kind, type: :string, aliases: %w[-k], desc: "Filter by kind"
17
+ option :tags, type: :string, aliases: %w[-T], desc: "Filter by tags (comma-separated)"
18
+ option :in, type: :string, aliases: %w[-i], desc: "Folder filter (next, all, archive)"
19
+ option :scope, type: :string, desc: "Scope (current, all)"
20
+
21
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
22
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
23
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
24
+
25
+ def call(**options)
26
+ scope = validate_scope(options[:scope])
27
+ status = options[:status]
28
+ kind = options[:kind]
29
+ tags = parse_tags(options[:tags])
30
+ in_folder = options[:in] || "next"
31
+
32
+ manager = Ace::Hitl::Organisms::HitlManager.new
33
+ events = manager.list(status: status, kind: kind, tags: tags, in_folder: in_folder, scope: scope)
34
+
35
+ puts Ace::Hitl::Molecules::HitlDisplayFormatter.format_list(
36
+ events,
37
+ total_count: manager.last_list_total,
38
+ global_folder_stats: manager.last_folder_counts
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def parse_tags(raw)
45
+ return [] unless raw
46
+
47
+ raw.split(",").map(&:strip).reject(&:empty?)
48
+ end
49
+
50
+ def validate_scope(raw_scope)
51
+ return nil if raw_scope.nil?
52
+
53
+ scope = raw_scope.to_s.strip
54
+ allowed = Ace::Hitl::Molecules::WorktreeScopeResolver::VALID_SCOPES
55
+ return scope if allowed.include?(scope)
56
+
57
+ raise Ace::Support::Cli::Error.new("Invalid scope '#{raw_scope}'. Allowed: #{allowed.join(", ")}")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ class Show < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Show HITL event details"
13
+
14
+ argument :ref, required: true, desc: "HITL reference (full ID or shortcut)"
15
+
16
+ option :path, type: :boolean, desc: "Print file path only"
17
+ option :content, type: :boolean, desc: "Print raw markdown content"
18
+ option :scope, type: :string, desc: "Scope (current, all)"
19
+
20
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
21
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
22
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
23
+
24
+ def call(ref:, **options)
25
+ scope = validate_scope(options[:scope])
26
+ manager = Ace::Hitl::Organisms::HitlManager.new
27
+ begin
28
+ resolved = manager.show(ref, scope: scope)
29
+ rescue Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError => e
30
+ candidates = e.matches.map(&:file_path).join(", ")
31
+ raise Ace::Support::Cli::Error.new(
32
+ "HITL event '#{ref}' is ambiguous across scope '#{scope || "all"}'. Candidates: #{candidates}"
33
+ )
34
+ end
35
+ raise Ace::Support::Cli::Error.new("HITL event '#{ref}' not found") unless resolved
36
+ event = resolved[:event]
37
+ resolved_location = format_resolved_location(resolved)
38
+
39
+ if options[:path]
40
+ puts event.file_path
41
+ elsif options[:content]
42
+ puts resolved_location if resolved_location
43
+ puts File.read(event.file_path)
44
+ else
45
+ puts "ID: #{event.id}"
46
+ puts "Title: #{event.title}"
47
+ puts "Status: #{event.status}"
48
+ puts "Kind: #{event.kind}"
49
+ puts "Tags: #{event.tags.join(", ")}"
50
+ puts "Questions: #{event.questions.join(" | ")}"
51
+ puts "Answer: #{event.answer || "(none)"}"
52
+ puts resolved_location if resolved_location
53
+ puts "Path: #{event.file_path}"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def validate_scope(raw_scope)
60
+ return nil if raw_scope.nil?
61
+
62
+ scope = raw_scope.to_s.strip
63
+ allowed = Ace::Hitl::Molecules::WorktreeScopeResolver::VALID_SCOPES
64
+ return scope if allowed.include?(scope)
65
+
66
+ raise Ace::Support::Cli::Error.new("Invalid scope '#{raw_scope}'. Allowed: #{allowed.join(", ")}")
67
+ end
68
+
69
+ def format_resolved_location(resolved)
70
+ return nil unless resolved[:resolved_outside_current]
71
+
72
+ worktree = resolved[:resolved_worktree_root] || "(unknown worktree)"
73
+ "Resolved Location: worktree=#{worktree} path=#{resolved[:event].file_path}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/support/items"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ class Update < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc "Update HITL event metadata and/or answer"
14
+
15
+ argument :ref, required: true, desc: "HITL reference (full ID or shortcut)"
16
+
17
+ option :set, type: :string, repeat: true, desc: "Set field: key=value"
18
+ option :add, type: :string, repeat: true, desc: "Add field: key=value"
19
+ option :remove, type: :string, repeat: true, desc: "Remove field: key=value"
20
+ option :"move-to", type: :string, aliases: %w[-m], desc: "Move to folder (archive, next)"
21
+ option :answer, type: :string, desc: "Write ## Answer body and mark answered"
22
+ option :resume, type: :boolean, desc: "Dispatch resume after answer using waiter/session metadata"
23
+ option :scope, type: :string, desc: "Scope (current, all)"
24
+
25
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
26
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
27
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
28
+
29
+ def call(ref:, **options)
30
+ set_args = Array(options[:set])
31
+ add_args = Array(options[:add])
32
+ remove_args = Array(options[:remove])
33
+ move_to = options[:"move-to"]
34
+ answer = options[:answer]
35
+ resume = options[:resume] == true
36
+ scope = validate_scope(options[:scope])
37
+
38
+ if set_args.empty? && add_args.empty? && remove_args.empty? && move_to.nil? && answer.nil? && !resume
39
+ raise Ace::Support::Cli::Error.new("No update operations specified")
40
+ end
41
+
42
+ set_hash = parse_kv_pairs(set_args)
43
+ add_hash = parse_kv_pairs(add_args)
44
+ remove_hash = parse_kv_pairs(remove_args)
45
+
46
+ manager = Ace::Hitl::Organisms::HitlManager.new
47
+ event = begin
48
+ manager.update(
49
+ ref,
50
+ set: set_hash,
51
+ add: add_hash,
52
+ remove: remove_hash,
53
+ move_to: move_to,
54
+ answer: answer,
55
+ scope: scope
56
+ )
57
+ rescue Ace::Hitl::Organisms::HitlManager::AmbiguousReferenceError => e
58
+ candidates = e.matches.map(&:file_path).join(", ")
59
+ raise Ace::Support::Cli::Error.new(
60
+ "HITL event '#{ref}' is ambiguous across scope '#{scope || "all"}'. Candidates: #{candidates}"
61
+ )
62
+ end
63
+
64
+ raise Ace::Support::Cli::Error.new("HITL event '#{ref}' not found") unless event
65
+
66
+ if move_to
67
+ folder_info = event.special_folder || "root"
68
+ puts "HITL event updated: #{event.id} #{event.title} -> #{folder_info}"
69
+ else
70
+ puts "HITL event updated: #{event.id} #{event.title}"
71
+ end
72
+
73
+ return unless resume
74
+
75
+ dispatch = manager.dispatch_resume(event.id, scope: scope)
76
+ case dispatch[:status]
77
+ when :waiter_active
78
+ puts "Resume dispatch skipped: active waiter lease detected."
79
+ when :dispatched
80
+ puts "Resume dispatched: mode=#{dispatch[:mode]} details=#{dispatch[:details]}"
81
+ puts "HITL event archived after successful dispatch."
82
+ when :no_answer
83
+ raise Ace::Support::Cli::Error.new("Cannot resume '#{event.id}' without an answer")
84
+ when :failed
85
+ raise Ace::Support::Cli::Error.new(dispatch[:error] || "Resume dispatch failed")
86
+ else
87
+ raise Ace::Support::Cli::Error.new("Resume dispatch failed: unexpected status #{dispatch[:status]}")
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def parse_kv_pairs(args)
94
+ result = {}
95
+ args.each do |arg|
96
+ unless arg.include?("=")
97
+ raise Ace::Support::Cli::Error.new("Invalid format '#{arg}': expected key=value")
98
+ end
99
+
100
+ parsed = Ace::Support::Items::Atoms::FieldArgumentParser.parse([arg])
101
+ parsed.each do |key, value|
102
+ result[key] = if result.key?(key)
103
+ Array(result[key]) + Array(value)
104
+ else
105
+ value
106
+ end
107
+ end
108
+ rescue Ace::Support::Items::Atoms::FieldArgumentParser::ParseError => e
109
+ raise Ace::Support::Cli::Error.new("Invalid argument '#{arg}': #{e.message}")
110
+ end
111
+ result
112
+ end
113
+
114
+ def validate_scope(raw_scope)
115
+ return nil if raw_scope.nil?
116
+
117
+ scope = raw_scope.to_s.strip
118
+ allowed = Ace::Hitl::Molecules::WorktreeScopeResolver::VALID_SCOPES
119
+ return scope if allowed.include?(scope)
120
+
121
+ raise Ace::Support::Cli::Error.new("Invalid scope '#{raw_scope}'. Allowed: #{allowed.join(", ")}")
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ class Wait < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Wait for a specific HITL event answer (polling default)"
13
+
14
+ argument :ref, required: true, desc: "HITL reference (full ID or shortcut)"
15
+
16
+ option :"poll-every", type: :integer, desc: "Polling interval in seconds (default: 600)"
17
+ option :timeout, type: :integer, desc: "Max wait time in seconds (default: 14400)"
18
+ option :scope, type: :string, desc: "Scope (current, all)"
19
+ option :"session-id", type: :string, desc: "Waiter session id (optional)"
20
+ option :provider, type: :string, desc: "Waiter provider (optional)"
21
+
22
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
23
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
24
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
25
+
26
+ def call(ref:, **options)
27
+ scope = validate_scope(options[:scope])
28
+ poll_every = options[:"poll-every"] || 600
29
+ timeout = options[:timeout] || 14_400
30
+
31
+ waiter = {
32
+ session_id: options[:"session-id"] || ENV["ACE_AGENT_SESSION_ID"] || ENV["ACE_SESSION_ID"],
33
+ provider: options[:provider] || ENV["ACE_PROVIDER"]
34
+ }
35
+
36
+ manager = Ace::Hitl::Organisms::HitlManager.new
37
+ result = manager.wait_for_answer(
38
+ ref,
39
+ scope: scope,
40
+ poll_every: poll_every,
41
+ timeout: timeout,
42
+ waiter: waiter
43
+ )
44
+
45
+ case result[:status]
46
+ when :answered
47
+ event = result[:event]
48
+ puts "HITL event answered: #{event.id} #{event.title}"
49
+ puts "Answer: #{event.answer}"
50
+ resume = event.metadata["resume_instructions"]
51
+ puts "Resume: #{resume}" if resume
52
+ when :timeout
53
+ raise Ace::Support::Cli::Error.new("Timed out waiting for HITL event '#{ref}'")
54
+ else
55
+ raise Ace::Support::Cli::Error.new("HITL event '#{ref}' not found")
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def validate_scope(raw_scope)
62
+ return nil if raw_scope.nil?
63
+
64
+ scope = raw_scope.to_s.strip
65
+ allowed = Ace::Hitl::Molecules::WorktreeScopeResolver::VALID_SCOPES
66
+ return scope if allowed.include?(scope)
67
+
68
+ raise Ace::Support::Cli::Error.new("Invalid scope '#{raw_scope}'. Allowed: #{allowed.join(", ")}")
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "../hitl/version"
5
+ require_relative "cli/commands/create"
6
+ require_relative "cli/commands/show"
7
+ require_relative "cli/commands/list"
8
+ require_relative "cli/commands/update"
9
+ require_relative "cli/commands/wait"
10
+
11
+ module Ace
12
+ module Hitl
13
+ module HitlCLI
14
+ extend Ace::Support::Cli::RegistryDsl
15
+
16
+ PROGRAM_NAME = "ace-hitl"
17
+
18
+ REGISTERED_COMMANDS = [
19
+ ["create", "Create HITL event"],
20
+ ["show", "Show HITL event details"],
21
+ ["list", "List HITL events"],
22
+ ["update", "Update HITL event metadata or answer"],
23
+ ["wait", "Wait for an answer on a specific HITL event"]
24
+ ].freeze
25
+
26
+ HELP_EXAMPLES = [
27
+ "ace-hitl list --status pending",
28
+ "ace-hitl show abc123 --content",
29
+ "ace-hitl create \"Which auth strategy?\" --kind decision",
30
+ "ace-hitl update abc123 --answer \"Use JWT with refresh tokens\"",
31
+ "ace-hitl wait abc123 --poll-every 600 --timeout 14400"
32
+ ].freeze
33
+
34
+ register "create", CLI::Commands::Create
35
+ register "show", CLI::Commands::Show
36
+ register "list", CLI::Commands::List
37
+ register "update", CLI::Commands::Update
38
+ register "wait", CLI::Commands::Wait
39
+
40
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
41
+ gem_name: "ace-hitl",
42
+ version: Ace::Hitl::VERSION
43
+ )
44
+ register "version", version_cmd
45
+ register "--version", version_cmd
46
+
47
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
48
+ program_name: PROGRAM_NAME,
49
+ version: Ace::Hitl::VERSION,
50
+ commands: REGISTERED_COMMANDS,
51
+ examples: HELP_EXAMPLES
52
+ )
53
+ register "help", help_cmd
54
+ register "--help", help_cmd
55
+ register "-h", help_cmd
56
+
57
+ def self.start(args)
58
+ Ace::Support::Cli::Runner.new(self).call(args: args)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Models
6
+ HitlEvent = Struct.new(
7
+ :id,
8
+ :status,
9
+ :kind,
10
+ :title,
11
+ :tags,
12
+ :questions,
13
+ :answer,
14
+ :content,
15
+ :path,
16
+ :file_path,
17
+ :special_folder,
18
+ :created_at,
19
+ :metadata,
20
+ keyword_init: true
21
+ ) do
22
+ def shortcut
23
+ id[-3..]
24
+ end
25
+
26
+ def answered?
27
+ status.to_s == "answered" || metadata["answered"] == true
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Molecules
6
+ class HitlAnswerEditor
7
+ ANSWER_HEADER = /^## Answer\s*$/
8
+
9
+ def self.apply(body, answer)
10
+ answer_text = answer.to_s.strip
11
+ replacement = "## Answer\n\n#{answer_text}\n"
12
+ source = body.to_s.rstrip
13
+
14
+ if source.match?(ANSWER_HEADER)
15
+ source.sub(/^## Answer[ \t]*(?:\n.*)?\z/m, replacement)
16
+ elsif source.empty?
17
+ replacement
18
+ else
19
+ "#{source}\n\n#{replacement}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/config"
5
+ require "ace/support/fs"
6
+
7
+ module Ace
8
+ module Hitl
9
+ module Molecules
10
+ class HitlConfigLoader
11
+ DEFAULT_ROOT_DIR = ".ace-local/hitl"
12
+ DEFAULT_KIND = "clarification"
13
+
14
+ def self.load(gem_root: nil)
15
+ gem_root ||= File.expand_path("../../../..", __dir__)
16
+ resolver = Ace::Support::Config.create(
17
+ config_dir: ".ace",
18
+ defaults_dir: ".ace-defaults",
19
+ gem_path: gem_root
20
+ )
21
+ {"hitl" => resolver.resolve_namespace("hitl").data}
22
+ rescue StandardError => e
23
+ warn "ace-hitl: Could not load config: #{e.class} - #{e.message}" if Ace::Hitl.respond_to?(:debug?) && Ace::Hitl.debug?
24
+ load_defaults_fallback(gem_root: gem_root)
25
+ end
26
+
27
+ def self.root_dir(config = nil)
28
+ config ||= load
29
+ dir = config.dig("hitl", "root_dir") || DEFAULT_ROOT_DIR
30
+
31
+ if dir.start_with?("/")
32
+ dir
33
+ else
34
+ File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, dir)
35
+ end
36
+ end
37
+
38
+ def self.load_defaults_fallback(gem_root:)
39
+ defaults_path = File.join(gem_root, ".ace-defaults", "hitl", "config.yml")
40
+ return {} unless File.exist?(defaults_path)
41
+
42
+ YAML.safe_load_file(defaults_path, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
43
+ rescue StandardError
44
+ {}
45
+ end
46
+ private_class_method :load_defaults_fallback
47
+ end
48
+ end
49
+ end
50
+ end