ace-task 0.31.7 → 0.36.1
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.
- checksums.yaml +4 -4
- data/.ace-defaults/task/config.yml +2 -2
- data/CHANGELOG.md +171 -0
- data/README.md +7 -0
- data/handbook/skills/as-task-draft/SKILL.md +7 -1
- data/handbook/skills/as-task-plan/SKILL.md +6 -1
- data/handbook/skills/as-task-work/SKILL.md +6 -1
- data/handbook/workflow-instructions/bug/analyze.wf.md +5 -5
- data/handbook/workflow-instructions/bug/fix.wf.md +2 -2
- data/handbook/workflow-instructions/task/draft.wf.md +20 -1
- data/handbook/workflow-instructions/task/review.wf.md +22 -0
- data/handbook/workflow-instructions/task/work.wf.md +3 -0
- data/lib/ace/task/atoms/task_frontmatter_defaults.rb +12 -1
- data/lib/ace/task/atoms/task_validation_rules.rb +1 -1
- data/lib/ace/task/cli/commands/create.rb +46 -4
- data/lib/ace/task/cli/commands/github_sync.rb +65 -0
- data/lib/ace/task/cli/commands/plan.rb +8 -4
- data/lib/ace/task/cli.rb +7 -2
- data/lib/ace/task/molecules/github_issue_sync_adapter.rb +77 -0
- data/lib/ace/task/molecules/subtask_creator.rb +12 -2
- data/lib/ace/task/molecules/task_creator.rb +75 -35
- data/lib/ace/task/molecules/task_frontmatter_validator.rb +15 -0
- data/lib/ace/task/molecules/task_plan_generator.rb +13 -1
- data/lib/ace/task/organisms/task_doctor.rb +31 -1
- data/lib/ace/task/organisms/task_manager.rb +156 -7
- data/lib/ace/task/version.rb +1 -1
- data/lib/ace/task.rb +2 -0
- metadata +18 -2
|
@@ -24,6 +24,7 @@ module Ace
|
|
|
24
24
|
"q7w # Reuse fresh plan or generate new",
|
|
25
25
|
"q7w --refresh # Force regeneration",
|
|
26
26
|
"q7w --content # Print full plan content",
|
|
27
|
+
"q7w --timeout 30 # Fail fast if plan generation stalls",
|
|
27
28
|
"q7w --model gemini:flash-latest # Override planning model"
|
|
28
29
|
]
|
|
29
30
|
|
|
@@ -32,6 +33,7 @@ module Ace
|
|
|
32
33
|
option :refresh, type: :boolean, desc: "Force plan regeneration"
|
|
33
34
|
option :content, type: :boolean, desc: "Print full plan content instead of path"
|
|
34
35
|
option :model, type: :string, desc: "Provider:model override for plan generation"
|
|
36
|
+
option :timeout, type: :integer, desc: "LLM request timeout in seconds for plan generation"
|
|
35
37
|
|
|
36
38
|
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
37
39
|
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
@@ -58,7 +60,7 @@ module Ace
|
|
|
58
60
|
|
|
59
61
|
context_files = capture_context_files(task)
|
|
60
62
|
model = options[:model] || default_model(config)
|
|
61
|
-
generator = plan_generator(model)
|
|
63
|
+
generator = plan_generator(model, timeout: options[:timeout])
|
|
62
64
|
content = generator.generate(
|
|
63
65
|
task: task,
|
|
64
66
|
context_files: context_files,
|
|
@@ -104,13 +106,15 @@ module Ace
|
|
|
104
106
|
end
|
|
105
107
|
end
|
|
106
108
|
|
|
107
|
-
def plan_generator(model, cli_args: nil)
|
|
109
|
+
def plan_generator(model, cli_args: nil, timeout: nil)
|
|
108
110
|
klass = self.class.generator_class || Molecules::TaskPlanGenerator
|
|
109
|
-
|
|
111
|
+
kwargs = {model: model, cli_args: cli_args}
|
|
112
|
+
kwargs[:timeout] = timeout if timeout
|
|
113
|
+
klass.new(**kwargs)
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
def default_model(config)
|
|
113
|
-
config.dig("task", "plan", "model") || "
|
|
117
|
+
config.dig("task", "plan", "model") || "role:planner"
|
|
114
118
|
end
|
|
115
119
|
end
|
|
116
120
|
end
|
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(
|
|
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(
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -7,6 +7,7 @@ require_relative "../molecules/task_loader"
|
|
|
7
7
|
require_relative "../molecules/task_creator"
|
|
8
8
|
require_relative "../molecules/subtask_creator"
|
|
9
9
|
require_relative "../molecules/task_reparenter"
|
|
10
|
+
require_relative "../molecules/github_issue_sync_adapter"
|
|
10
11
|
require_relative "../atoms/task_validation_rules"
|
|
11
12
|
|
|
12
13
|
module Ace
|
|
@@ -15,6 +16,9 @@ module Ace
|
|
|
15
16
|
# Orchestrates all task CRUD operations.
|
|
16
17
|
# Entry point for task management with config-driven root directory.
|
|
17
18
|
class TaskManager
|
|
19
|
+
CREATE_RETRY_LIMIT = 3
|
|
20
|
+
class CreateRetriesExhaustedError < StandardError; end
|
|
21
|
+
|
|
18
22
|
attr_reader :last_list_total, :last_folder_counts
|
|
19
23
|
|
|
20
24
|
# @param root_dir [String, nil] Override root directory for tasks
|
|
@@ -35,11 +39,41 @@ module Ace
|
|
|
35
39
|
# @param dependencies [Array<String>] Dependency task IDs
|
|
36
40
|
# @param use_llm_slug [Boolean] Whether to attempt LLM slug generation
|
|
37
41
|
# @return [Models::Task] Created task
|
|
38
|
-
def create(
|
|
42
|
+
def create(
|
|
43
|
+
title,
|
|
44
|
+
status: nil,
|
|
45
|
+
priority: nil,
|
|
46
|
+
tags: [],
|
|
47
|
+
dependencies: [],
|
|
48
|
+
use_llm_slug: false,
|
|
49
|
+
estimate: nil,
|
|
50
|
+
github_issue: nil
|
|
51
|
+
)
|
|
39
52
|
ensure_root_dir
|
|
53
|
+
ensure_github_issue_linkable!(github_issue)
|
|
40
54
|
creator = Molecules::TaskCreator.new(root_dir: @root_dir, config: @config)
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
attempts = 0
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
attempts += 1
|
|
59
|
+
created_task = creator.create(
|
|
60
|
+
title,
|
|
61
|
+
status: status,
|
|
62
|
+
priority: priority,
|
|
63
|
+
tags: tags,
|
|
64
|
+
dependencies: dependencies,
|
|
65
|
+
use_llm_slug: use_llm_slug,
|
|
66
|
+
time: Time.now.utc + ((attempts - 1) * 2),
|
|
67
|
+
estimate: estimate,
|
|
68
|
+
github_issue: github_issue
|
|
69
|
+
)
|
|
70
|
+
sync_linked_issues_for(created_task, reason: "create")
|
|
71
|
+
created_task
|
|
72
|
+
rescue Molecules::TaskCreator::IdCollisionError
|
|
73
|
+
retry if attempts < CREATE_RETRY_LIMIT
|
|
74
|
+
raise CreateRetriesExhaustedError,
|
|
75
|
+
"Failed to create task: unable to generate a unique ID after #{CREATE_RETRY_LIMIT} attempts"
|
|
76
|
+
end
|
|
43
77
|
end
|
|
44
78
|
|
|
45
79
|
# Show (load) a single task by reference, including subtasks.
|
|
@@ -109,6 +143,8 @@ module Ace
|
|
|
109
143
|
|
|
110
144
|
# Apply field updates if any
|
|
111
145
|
has_field_updates = [set, add, remove].any? { |h| h && !h.empty? }
|
|
146
|
+
desired_issue = extract_desired_github_issue(task, set: set, remove: remove)
|
|
147
|
+
ensure_github_issue_linkable!(desired_issue, previous_task: task) if desired_issue
|
|
112
148
|
if has_field_updates
|
|
113
149
|
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
114
150
|
task.file_path, set: set, add: add, remove: remove
|
|
@@ -146,7 +182,9 @@ module Ace
|
|
|
146
182
|
resolve_fn = ->(r) { show(r) }
|
|
147
183
|
# Reload task from current path before reparenting (may have been field-updated)
|
|
148
184
|
task_for_reparent = loader.load(current_path, id: task.id, special_folder: current_special)
|
|
149
|
-
|
|
185
|
+
reparented = reparenter.reparent(task_for_reparent, target: move_as_child_of, resolve_ref: resolve_fn)
|
|
186
|
+
sync_linked_issues_for(reparented, reason: "reparent", previous_task: task)
|
|
187
|
+
return reparented
|
|
150
188
|
end
|
|
151
189
|
|
|
152
190
|
# Auto-archive hook: if a subtask status was set to terminal,
|
|
@@ -156,7 +194,11 @@ module Ace
|
|
|
156
194
|
end
|
|
157
195
|
|
|
158
196
|
# Reload and return updated task
|
|
159
|
-
loader.load(current_path, id: current_id, special_folder: current_special)
|
|
197
|
+
updated_task = loader.load(current_path, id: current_id, special_folder: current_special)
|
|
198
|
+
if sync_needed_after_update?(task, updated_task, set: set, add: add, remove: remove, move_to: move_to)
|
|
199
|
+
sync_linked_issues_for(updated_task, reason: "update", previous_task: task)
|
|
200
|
+
end
|
|
201
|
+
updated_task
|
|
160
202
|
end
|
|
161
203
|
|
|
162
204
|
# Create a subtask within a parent task.
|
|
@@ -166,12 +208,45 @@ module Ace
|
|
|
166
208
|
# @param priority [String, nil] Priority level
|
|
167
209
|
# @param tags [Array<String>] Tags
|
|
168
210
|
# @return [Models::Task, nil] Created subtask or nil if parent not found
|
|
169
|
-
def create_subtask(parent_ref, title, status: nil, priority: nil, tags: [], estimate: nil)
|
|
211
|
+
def create_subtask(parent_ref, title, status: nil, priority: nil, tags: [], estimate: nil, github_issue: nil)
|
|
170
212
|
parent = show(parent_ref)
|
|
171
213
|
return nil unless parent
|
|
172
214
|
|
|
215
|
+
ensure_github_issue_linkable!(github_issue)
|
|
173
216
|
subtask_creator = Molecules::SubtaskCreator.new(config: @config)
|
|
174
|
-
subtask_creator.create(
|
|
217
|
+
created_subtask = subtask_creator.create(
|
|
218
|
+
parent,
|
|
219
|
+
title,
|
|
220
|
+
status: status,
|
|
221
|
+
priority: priority,
|
|
222
|
+
tags: tags,
|
|
223
|
+
estimate: estimate,
|
|
224
|
+
github_issue: github_issue
|
|
225
|
+
)
|
|
226
|
+
sync_linked_issues_for(created_subtask, reason: "create")
|
|
227
|
+
created_subtask
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def github_sync(ref: nil, all: false)
|
|
231
|
+
raise ArgumentError, "Provide --all or a task reference" if !all && (ref.nil? || ref.strip.empty?)
|
|
232
|
+
|
|
233
|
+
if all
|
|
234
|
+
tasks = list(in_folder: "all")
|
|
235
|
+
linked_tasks = tasks.select { |t| linked_issue_id(t) }
|
|
236
|
+
results = linked_tasks.map { |task| sync_linked_issues_for(task, reason: "manual-sync") }
|
|
237
|
+
return summarize_manual_sync_results(results, skipped: tasks.length - linked_tasks.length)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
task = show(ref)
|
|
241
|
+
return nil unless task
|
|
242
|
+
|
|
243
|
+
unless linked_issue_id(task)
|
|
244
|
+
return {synced: 0, failed: 0, skipped: 1, task_id: task.id, failures: []}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
result = sync_linked_issues_for(task, reason: "manual-sync")
|
|
248
|
+
summary = summarize_manual_sync_results([result], skipped: 0)
|
|
249
|
+
summary.merge(task_id: task.id)
|
|
175
250
|
end
|
|
176
251
|
|
|
177
252
|
# Get the root directory.
|
|
@@ -347,6 +422,80 @@ module Ace
|
|
|
347
422
|
item.metadata[key] || item.metadata[key.to_sym] if item.respond_to?(:metadata) && item.metadata
|
|
348
423
|
end
|
|
349
424
|
end
|
|
425
|
+
|
|
426
|
+
def sync_needed_after_update?(before_task, after_task, set:, add:, remove:, move_to:)
|
|
427
|
+
return false unless after_task
|
|
428
|
+
return true if move_to
|
|
429
|
+
return true if before_task.path != after_task.path
|
|
430
|
+
return true if linked_issue_id(before_task) != linked_issue_id(after_task)
|
|
431
|
+
|
|
432
|
+
touched_keys = [set, add, remove].compact.flat_map(&:keys).map(&:to_s)
|
|
433
|
+
touched_keys.any? do |key|
|
|
434
|
+
key == "title" || key == "status" || key == "github_issue"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def linked_issue_id(task)
|
|
439
|
+
return nil unless task&.metadata
|
|
440
|
+
|
|
441
|
+
issue_id = task.metadata["github_issue"]
|
|
442
|
+
return nil unless issue_id.to_i.positive?
|
|
443
|
+
|
|
444
|
+
issue_id.to_i
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def extract_desired_github_issue(task, set:, remove:)
|
|
448
|
+
return nil if set&.key?("github_issue") && !set["github_issue"].to_i.positive?
|
|
449
|
+
return set["github_issue"].to_i if set&.key?("github_issue") && set["github_issue"].to_i.positive?
|
|
450
|
+
return nil if Array(remove&.keys).map(&:to_s).include?("github_issue")
|
|
451
|
+
|
|
452
|
+
linked_issue_id(task)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def ensure_github_issue_linkable!(github_issue, previous_task: nil)
|
|
456
|
+
return unless github_issue
|
|
457
|
+
|
|
458
|
+
Molecules::GithubIssueSyncAdapter.new.validate_link!(issue_id: github_issue, previous_task: previous_task)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def sync_linked_issues_for(task, reason:, previous_task: nil)
|
|
462
|
+
issue_ids = [linked_issue_id(task), linked_issue_id(previous_task)].compact.uniq
|
|
463
|
+
return sync_result_for(task: task, issues: issue_ids, success: true, reason: reason) if issue_ids.empty?
|
|
464
|
+
|
|
465
|
+
adapter = Molecules::GithubIssueSyncAdapter.new
|
|
466
|
+
adapter.sync_task(task: task, reason: reason, previous_task: previous_task)
|
|
467
|
+
sync_result_for(task: task, issues: issue_ids, success: true, reason: reason)
|
|
468
|
+
rescue StandardError => e
|
|
469
|
+
@last_update_note = "GitHub sync warning for task #{task&.id}: #{e.message}"
|
|
470
|
+
sync_result_for(task: task, issues: issue_ids, success: false, reason: reason, error: e.message)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def sync_result_for(task:, issues:, success:, reason:, error: nil)
|
|
474
|
+
{
|
|
475
|
+
task_id: task&.id,
|
|
476
|
+
issue_ids: issues,
|
|
477
|
+
success: success,
|
|
478
|
+
reason: reason,
|
|
479
|
+
error: error
|
|
480
|
+
}
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def summarize_manual_sync_results(results, skipped:)
|
|
484
|
+
failures = results.reject { |result| result[:success] }.map do |result|
|
|
485
|
+
{
|
|
486
|
+
task_id: result[:task_id],
|
|
487
|
+
issue_ids: result[:issue_ids],
|
|
488
|
+
error: result[:error]
|
|
489
|
+
}
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
{
|
|
493
|
+
synced: results.length - failures.length,
|
|
494
|
+
failed: failures.length,
|
|
495
|
+
skipped: skipped,
|
|
496
|
+
failures: failures
|
|
497
|
+
}
|
|
498
|
+
end
|
|
350
499
|
end
|
|
351
500
|
end
|
|
352
501
|
end
|