ace-task 0.31.9 → 0.36.7

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.
data/lib/ace/task/cli.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "cli/commands/update"
10
10
  require_relative "cli/commands/doctor"
11
11
  require_relative "cli/commands/status"
12
12
  require_relative "cli/commands/plan"
13
+ require_relative "cli/commands/github_sync"
13
14
 
14
15
  module Ace
15
16
  module Task
@@ -26,7 +27,8 @@ module Ace
26
27
  ["update", "Update task metadata (fields, move, reparent)"],
27
28
  ["doctor", "Run health checks on tasks"],
28
29
  ["status", "Show task status overview"],
29
- ["plan", "Resolve or generate implementation plan"]
30
+ ["plan", "Resolve or generate implementation plan"],
31
+ ["github-sync", "Sync linked GitHub issues for task(s)"]
30
32
  ].freeze
31
33
 
32
34
  HELP_EXAMPLES = [
@@ -45,7 +47,9 @@ module Ace
45
47
  "ace-task status --up-next-limit 5",
46
48
  "ace-task plan q7w",
47
49
  "ace-task plan q7w --refresh",
48
- "ace-task plan q7w --content"
50
+ "ace-task plan q7w --content",
51
+ "ace-task github-sync q7w",
52
+ "ace-task github-sync --all"
49
53
  ].freeze
50
54
 
51
55
  register "create", CLI::Commands::Create
@@ -55,6 +59,7 @@ module Ace
55
59
  register "doctor", CLI::Commands::Doctor
56
60
  register "status", CLI::Commands::Status
57
61
  register "plan", CLI::Commands::Plan
62
+ register "github-sync", CLI::Commands::GithubSync
58
63
 
59
64
  version_cmd = Ace::Support::Cli::VersionCommand.build(
60
65
  gem_name: "ace-task",
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Task
5
+ module Molecules
6
+ # Bridges ace-task lifecycle events to reusable ace-git issue sync primitives.
7
+ class GithubIssueSyncAdapter
8
+ UNAVAILABLE_MESSAGE = "GitHub issue sync primitives are unavailable. " \
9
+ "Complete dependency task 8r4.t.ilo.2 or update ace-git."
10
+
11
+ CANDIDATE_INTEGRATIONS = [
12
+ ["Ace::Git::Molecules::GithubIssueSync", :validate_link!],
13
+ ["Ace::Git::Molecules::IssueSync", :validate_link!],
14
+ ["Ace::Git::Molecules::GithubIssueSync", :sync_task],
15
+ ["Ace::Git::Molecules::IssueSync", :sync_task]
16
+ ].freeze
17
+
18
+ def validate_link!(issue_id:, previous_task: nil)
19
+ return unless issue_id.to_i.positive?
20
+
21
+ receiver, method_name = resolve_integration(:validate_link!)
22
+ raise UNAVAILABLE_MESSAGE unless receiver && method_name
23
+
24
+ receiver.public_send(
25
+ method_name,
26
+ issue_id: issue_id.to_i,
27
+ task_id: previous_task&.id,
28
+ previous_task_id: previous_task&.id
29
+ )
30
+ end
31
+
32
+ def sync_task(task:, reason:, previous_task: nil)
33
+ current_issue_ids = [task.metadata["github_issue"]].compact.map(&:to_i).uniq
34
+ previous_issue_ids = [previous_task&.metadata&.[]("github_issue")].compact.map(&:to_i).uniq
35
+ issue_ids = (current_issue_ids + previous_issue_ids).uniq
36
+ return {synced: 0, issues: []} if issue_ids.empty?
37
+
38
+ receiver, method_name = resolve_integration(:sync_task)
39
+ raise UNAVAILABLE_MESSAGE unless receiver && method_name
40
+
41
+ payload = {
42
+ task_id: task.id,
43
+ task_title: task.title,
44
+ task_status: task.status,
45
+ task_path: task.file_path || task.path,
46
+ issue_ids: issue_ids,
47
+ current_issue_ids: current_issue_ids,
48
+ reason: reason,
49
+ previous: previous_task ? {
50
+ id: previous_task.id,
51
+ title: previous_task.title,
52
+ status: previous_task.status,
53
+ path: previous_task.file_path || previous_task.path
54
+ } : nil
55
+ }
56
+ receiver.public_send(method_name, **payload)
57
+ {synced: issue_ids.length, issues: issue_ids}
58
+ end
59
+
60
+ private
61
+
62
+ def resolve_integration(expected_method = nil)
63
+ CANDIDATE_INTEGRATIONS.each do |constant_name, method_name|
64
+ next if expected_method && method_name != expected_method
65
+
66
+ klass = constant_name.split("::").reject(&:empty?).inject(Object) { |ctx, name| ctx.const_get(name) }
67
+ return [klass, method_name] if klass.respond_to?(method_name)
68
+ rescue NameError
69
+ next
70
+ end
71
+
72
+ [nil, nil]
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -32,7 +32,16 @@ module Ace
32
32
  # @param time [Time] Creation time (default: now)
33
33
  # @return [Models::Task] Created subtask
34
34
  # @raise [RangeError] If parent already has 36 subtasks
35
- def create(parent_task, title, status: nil, priority: nil, tags: [], time: Time.now.utc, estimate: nil)
35
+ def create(
36
+ parent_task,
37
+ title,
38
+ status: nil,
39
+ priority: nil,
40
+ tags: [],
41
+ time: Time.now.utc,
42
+ estimate: nil,
43
+ github_issue: nil
44
+ )
36
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
37
46
 
38
47
  # Find next available subtask character
@@ -60,7 +69,8 @@ module Ace
60
69
  tags: tags,
61
70
  created_at: time,
62
71
  parent: parent_task.id,
63
- estimate: estimate
72
+ estimate: estimate,
73
+ github_issue: github_issue
64
74
  )
65
75
 
66
76
  # Write spec file
@@ -13,6 +13,8 @@ module Ace
13
13
  # Generates folder structure and spec file with full frontmatter.
14
14
  # Optionally uses LLM for slug generation with deterministic fallback.
15
15
  class TaskCreator
16
+ class IdCollisionError < StandardError; end
17
+
16
18
  # @param root_dir [String] Root directory for tasks
17
19
  # @param config [Hash] Configuration hash
18
20
  def initialize(root_dir:, config: {})
@@ -29,52 +31,90 @@ module Ace
29
31
  # @param time [Time] Creation time (default: now)
30
32
  # @param use_llm_slug [Boolean] Whether to attempt LLM slug generation
31
33
  # @return [Models::Task] Created task
32
- def create(title, status: nil, priority: nil, tags: [], dependencies: [], time: Time.now.utc, use_llm_slug: false, estimate: nil)
34
+ def create(
35
+ title,
36
+ status: nil,
37
+ priority: nil,
38
+ tags: [],
39
+ dependencies: [],
40
+ time: Time.now.utc,
41
+ use_llm_slug: false,
42
+ estimate: nil,
43
+ github_issue: nil
44
+ )
33
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
34
46
 
35
47
  # Generate task ID
36
48
  item_id = Atoms::TaskIdFormatter.generate(time)
37
49
  formatted_id = item_id.formatted_id
38
50
 
39
- # Generate slugs
40
- slugs = if use_llm_slug
41
- generate_llm_slugs(title) || generate_slugs(title)
42
- else
43
- generate_slugs(title)
51
+ with_id_reservation(formatted_id) do
52
+ raise IdCollisionError, "Task ID collision detected for #{formatted_id}" if task_id_exists?(formatted_id)
53
+
54
+ # Generate slugs
55
+ slugs = if use_llm_slug
56
+ generate_llm_slugs(title) || generate_slugs(title)
57
+ else
58
+ generate_slugs(title)
59
+ end
60
+ folder_slug = slugs[:folder]
61
+ file_slug = slugs[:file]
62
+
63
+ # Create folder with folder_slug
64
+ folder_name = Atoms::TaskIdFormatter.folder_name(formatted_id, folder_slug)
65
+ task_dir = File.join(@root_dir, folder_name)
66
+ FileUtils.mkdir_p(task_dir)
67
+
68
+ begin
69
+ # Build frontmatter with all fields
70
+ effective_status = status || @config.dig("task", "default_status") || "pending"
71
+ frontmatter = Atoms::TaskFrontmatterDefaults.build(
72
+ id: formatted_id,
73
+ status: effective_status,
74
+ priority: priority,
75
+ tags: tags,
76
+ dependencies: dependencies,
77
+ created_at: time,
78
+ estimate: estimate,
79
+ github_issue: github_issue
80
+ )
81
+
82
+ # Write spec file with file_slug
83
+ spec_filename = Atoms::TaskIdFormatter.spec_filename(formatted_id, file_slug)
84
+ spec_file = File.join(task_dir, spec_filename)
85
+ content = build_spec_content(frontmatter: frontmatter, title: title)
86
+ File.write(spec_file, content)
87
+
88
+ # Load and return the created task
89
+ loader = TaskLoader.new
90
+ loader.load(task_dir, id: formatted_id)
91
+ rescue StandardError
92
+ FileUtils.rm_rf(task_dir) if Dir.exist?(task_dir)
93
+ raise
94
+ end
44
95
  end
45
- folder_slug = slugs[:folder]
46
- file_slug = slugs[:file]
47
-
48
- # Create folder with folder_slug
49
- folder_name = Atoms::TaskIdFormatter.folder_name(formatted_id, folder_slug)
50
- task_dir = File.join(@root_dir, folder_name)
51
- FileUtils.mkdir_p(task_dir)
52
-
53
- # Build frontmatter with all fields
54
- effective_status = status || @config.dig("task", "default_status") || "pending"
55
- frontmatter = Atoms::TaskFrontmatterDefaults.build(
56
- id: formatted_id,
57
- status: effective_status,
58
- priority: priority,
59
- tags: tags,
60
- dependencies: dependencies,
61
- created_at: time,
62
- estimate: estimate
63
- )
64
-
65
- # Write spec file with file_slug
66
- spec_filename = Atoms::TaskIdFormatter.spec_filename(formatted_id, file_slug)
67
- spec_file = File.join(task_dir, spec_filename)
68
- content = build_spec_content(frontmatter: frontmatter, title: title)
69
- File.write(spec_file, content)
70
-
71
- # Load and return the created task
72
- loader = TaskLoader.new
73
- loader.load(task_dir, id: formatted_id)
74
96
  end
75
97
 
76
98
  private
77
99
 
100
+ def task_id_exists?(formatted_id)
101
+ Dir.glob(File.join(@root_dir, "**", "#{formatted_id}-*")).any? do |path|
102
+ File.directory?(path)
103
+ end
104
+ end
105
+
106
+ def with_id_reservation(formatted_id)
107
+ reservation_path = File.join(@root_dir, ".ace-task-id-lock-#{formatted_id}")
108
+ acquired = false
109
+ Dir.mkdir(reservation_path)
110
+ acquired = true
111
+ yield
112
+ rescue Errno::EEXIST
113
+ raise IdCollisionError, "Task ID collision detected for #{formatted_id}"
114
+ ensure
115
+ FileUtils.rm_rf(reservation_path) if acquired && reservation_path && Dir.exist?(reservation_path)
116
+ end
117
+
78
118
  def generate_folder_slug(title)
79
119
  sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title)
80
120
  words = sanitized.split("-")
@@ -109,6 +109,8 @@ module Ace
109
109
  if title.length > Atoms::TaskValidationRules::MAX_TITLE_LENGTH
110
110
  issues << {type: :warning, message: "Title exceeds #{Atoms::TaskValidationRules::MAX_TITLE_LENGTH} characters (#{title.length} chars)", location: file_path}
111
111
  end
112
+
113
+ validate_github_fields(frontmatter, file_path, issues)
112
114
  end
113
115
 
114
116
  def validate_recommended_fields(frontmatter, file_path, issues)
@@ -131,6 +133,19 @@ module Ace
131
133
  issues << issue.merge(location: file_path)
132
134
  end
133
135
  end
136
+
137
+ def validate_github_fields(frontmatter, file_path, issues)
138
+ linked = frontmatter["github_issue"]
139
+ return if linked.nil?
140
+
141
+ unless linked.is_a?(Integer) && linked.positive?
142
+ issues << {
143
+ type: :error,
144
+ message: "Invalid GitHub issue ID '#{linked}' in github_issue (expected positive integer)",
145
+ location: file_path
146
+ }
147
+ end
148
+ end
134
149
  end
135
150
  end
136
151
  end
@@ -35,7 +35,9 @@ module Ace
35
35
  )
36
36
  rescue Ace::Support::Cli::Error
37
37
  raise
38
- rescue RuntimeError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Timeout::Error => e
38
+ rescue StandardError => e
39
+ raise unless recoverable_query_error?(e)
40
+
39
41
  raise Ace::Support::Cli::Error.new(
40
42
  "Plan generation failed: #{e.message}. Retry with --refresh or choose a working --model."
41
43
  )
@@ -135,6 +137,16 @@ module Ace
135
137
  require "ace/llm"
136
138
  Ace::LLM::QueryInterface
137
139
  end
140
+
141
+ def recoverable_query_error?(error)
142
+ return true if error.class.name == "Ace::LLM::ProviderError"
143
+
144
+ error.is_a?(RuntimeError) ||
145
+ error.is_a?(IOError) ||
146
+ error.is_a?(Errno::ENOENT) ||
147
+ error.is_a?(Errno::ECONNREFUSED) ||
148
+ error.is_a?(Timeout::Error)
149
+ end
138
150
  end
139
151
  end
140
152
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "../molecules/task_scanner"
4
4
  require_relative "../molecules/task_frontmatter_validator"
5
5
  require_relative "../molecules/task_structure_validator"
6
+ require_relative "../molecules/task_doctor_fixer"
6
7
  require_relative "../atoms/task_validation_rules"
7
8
 
8
9
  module Ace
@@ -108,13 +109,20 @@ module Ace
108
109
  scanner = Molecules::TaskScanner.new(@root_path)
109
110
  return unless scanner.root_exists?
110
111
 
111
- scan_results = scanner.scan
112
+ scan_results = frontmatter_scan_results(scanner)
112
113
  @stats[:tasks_scanned] = scan_results.size
114
+ id_locations = Hash.new { |hash, key| hash[key] = [] }
113
115
 
114
116
  scan_results.each do |scan_result|
115
117
  spec_file = scan_result.file_path
116
118
  next unless spec_file && File.exist?(spec_file)
117
119
 
120
+ content = File.read(spec_file)
121
+ frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
122
+ if frontmatter.is_a?(Hash) && frontmatter["id"] && !frontmatter["id"].to_s.strip.empty?
123
+ id_locations[frontmatter["id"]] << spec_file
124
+ end
125
+
118
126
  issues = Molecules::TaskFrontmatterValidator.validate(
119
127
  spec_file,
120
128
  special_folder: scan_result.special_folder
@@ -127,6 +135,8 @@ module Ace
127
135
  add_issue(issue[:type], issue[:message], issue[:location])
128
136
  end
129
137
  end
138
+
139
+ add_duplicate_id_issues(id_locations)
130
140
  end
131
141
 
132
142
  def run_scope_check
@@ -175,6 +185,26 @@ module Ace
175
185
  count
176
186
  end
177
187
 
188
+ def frontmatter_scan_results(scanner)
189
+ top_level_results = scanner.scan
190
+ subtask_results = top_level_results.flat_map { |scan_result|
191
+ scanner.scan_subtasks(scan_result.dir_path, parent_id: scan_result.id)
192
+ }
193
+ top_level_results + subtask_results
194
+ end
195
+
196
+ def add_duplicate_id_issues(id_locations)
197
+ id_locations.each do |id, locations|
198
+ next unless locations.size > 1
199
+
200
+ add_issue(
201
+ :error,
202
+ "Duplicate task ID '#{id}' found in: #{locations.sort.join(', ')}",
203
+ locations.first
204
+ )
205
+ end
206
+ end
207
+
178
208
  def add_issue(type, message, location = nil)
179
209
  issue = {type: type, message: message}
180
210
  issue[:location] = location if location