ace-task 0.31.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.
- checksums.yaml +7 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-task.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-task.yml +19 -0
- data/.ace-defaults/task/config.yml +25 -0
- data/CHANGELOG.md +518 -0
- data/README.md +52 -0
- data/Rakefile +12 -0
- data/exe/ace-task +22 -0
- data/handbook/guides/task-definition.g.md +156 -0
- data/handbook/skills/as-bug-analyze/SKILL.md +26 -0
- data/handbook/skills/as-bug-fix/SKILL.md +27 -0
- data/handbook/skills/as-task-document-unplanned/SKILL.md +27 -0
- data/handbook/skills/as-task-draft/SKILL.md +24 -0
- data/handbook/skills/as-task-finder/SKILL.md +27 -0
- data/handbook/skills/as-task-plan/SKILL.md +30 -0
- data/handbook/skills/as-task-review/SKILL.md +25 -0
- data/handbook/skills/as-task-review-questions/SKILL.md +25 -0
- data/handbook/skills/as-task-update/SKILL.md +21 -0
- data/handbook/skills/as-task-work/SKILL.md +41 -0
- data/handbook/templates/task/draft.template.md +166 -0
- data/handbook/templates/task/file-modification-checklist.template.md +26 -0
- data/handbook/templates/task/technical-approach.template.md +26 -0
- data/handbook/workflow-instructions/bug/analyze.wf.md +458 -0
- data/handbook/workflow-instructions/bug/fix.wf.md +512 -0
- data/handbook/workflow-instructions/task/document-unplanned.wf.md +222 -0
- data/handbook/workflow-instructions/task/draft.wf.md +552 -0
- data/handbook/workflow-instructions/task/finder.wf.md +22 -0
- data/handbook/workflow-instructions/task/plan.wf.md +489 -0
- data/handbook/workflow-instructions/task/review-plan.wf.md +144 -0
- data/handbook/workflow-instructions/task/review-questions.wf.md +411 -0
- data/handbook/workflow-instructions/task/review-work.wf.md +146 -0
- data/handbook/workflow-instructions/task/review.wf.md +351 -0
- data/handbook/workflow-instructions/task/update.wf.md +118 -0
- data/handbook/workflow-instructions/task/work.wf.md +106 -0
- data/lib/ace/task/atoms/task_file_pattern.rb +68 -0
- data/lib/ace/task/atoms/task_frontmatter_defaults.rb +46 -0
- data/lib/ace/task/atoms/task_id_formatter.rb +62 -0
- data/lib/ace/task/atoms/task_validation_rules.rb +51 -0
- data/lib/ace/task/cli/commands/create.rb +105 -0
- data/lib/ace/task/cli/commands/doctor.rb +206 -0
- data/lib/ace/task/cli/commands/list.rb +73 -0
- data/lib/ace/task/cli/commands/plan.rb +119 -0
- data/lib/ace/task/cli/commands/show.rb +58 -0
- data/lib/ace/task/cli/commands/status.rb +77 -0
- data/lib/ace/task/cli/commands/update.rb +183 -0
- data/lib/ace/task/cli.rb +83 -0
- data/lib/ace/task/models/task.rb +46 -0
- data/lib/ace/task/molecules/path_utils.rb +20 -0
- data/lib/ace/task/molecules/subtask_creator.rb +130 -0
- data/lib/ace/task/molecules/task_config_loader.rb +92 -0
- data/lib/ace/task/molecules/task_creator.rb +115 -0
- data/lib/ace/task/molecules/task_display_formatter.rb +221 -0
- data/lib/ace/task/molecules/task_doctor_fixer.rb +510 -0
- data/lib/ace/task/molecules/task_doctor_reporter.rb +264 -0
- data/lib/ace/task/molecules/task_frontmatter_validator.rb +138 -0
- data/lib/ace/task/molecules/task_loader.rb +119 -0
- data/lib/ace/task/molecules/task_plan_cache.rb +190 -0
- data/lib/ace/task/molecules/task_plan_generator.rb +141 -0
- data/lib/ace/task/molecules/task_plan_prompt_builder.rb +91 -0
- data/lib/ace/task/molecules/task_reparenter.rb +247 -0
- data/lib/ace/task/molecules/task_resolver.rb +115 -0
- data/lib/ace/task/molecules/task_scanner.rb +129 -0
- data/lib/ace/task/molecules/task_structure_validator.rb +154 -0
- data/lib/ace/task/organisms/task_doctor.rb +199 -0
- data/lib/ace/task/organisms/task_manager.rb +353 -0
- data/lib/ace/task/version.rb +7 -0
- data/lib/ace/task.rb +37 -0
- metadata +197 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require "ace/support/items"
|
|
7
|
+
require "ace/b36ts"
|
|
8
|
+
require_relative "path_utils"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Task
|
|
12
|
+
module Molecules
|
|
13
|
+
# Manages task plan cache lookup, freshness checks, and artifact writes.
|
|
14
|
+
class TaskPlanCache
|
|
15
|
+
LATEST_POINTER = "latest-plan.md"
|
|
16
|
+
|
|
17
|
+
def initialize(task_id:, cache_root: ".ace-local/task")
|
|
18
|
+
@task_id = task_id
|
|
19
|
+
@cache_root = cache_root
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cache_dir
|
|
23
|
+
File.join(Dir.pwd, @cache_root, @task_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prompts_dir
|
|
27
|
+
File.join(cache_dir, "prompts")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def latest_pointer_path
|
|
31
|
+
File.join(cache_dir, LATEST_POINTER)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_latest_plan
|
|
35
|
+
from_pointer = resolve_from_pointer
|
|
36
|
+
return from_pointer if from_pointer
|
|
37
|
+
|
|
38
|
+
plan_files.max_by { |path| File.mtime(path) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fresh?(plan_path, task_file:)
|
|
42
|
+
return false unless File.file?(plan_path)
|
|
43
|
+
|
|
44
|
+
metadata = read_metadata(plan_path)
|
|
45
|
+
return false unless metadata
|
|
46
|
+
|
|
47
|
+
fresh_task_file?(metadata, task_file) && fresh_context_files?(metadata)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_plan(content:, model:, task_file:, context_files:, prompt_files: nil)
|
|
51
|
+
FileUtils.mkdir_p(cache_dir)
|
|
52
|
+
|
|
53
|
+
plan_path = build_unique_plan_path
|
|
54
|
+
|
|
55
|
+
metadata = {
|
|
56
|
+
"task_id" => @task_id,
|
|
57
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
58
|
+
"model" => model,
|
|
59
|
+
"task_file" => {
|
|
60
|
+
"path" => relative_path(task_file),
|
|
61
|
+
"mtime" => File.mtime(task_file).to_i
|
|
62
|
+
},
|
|
63
|
+
"context_files" => context_files.map { |path|
|
|
64
|
+
{
|
|
65
|
+
"path" => relative_path(path),
|
|
66
|
+
"mtime" => File.mtime(path).to_i
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if prompt_files
|
|
72
|
+
metadata["prompt_files"] = prompt_files.transform_values { |path|
|
|
73
|
+
relative_path(path)
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
body = "#{YAML.dump(metadata)}---\n\n"
|
|
78
|
+
body << content.to_s.rstrip
|
|
79
|
+
body << "\n"
|
|
80
|
+
File.write(plan_path, body)
|
|
81
|
+
|
|
82
|
+
File.write(latest_pointer_path, "#{File.basename(plan_path)}\n")
|
|
83
|
+
File.write(
|
|
84
|
+
File.join(cache_dir, "latest-plan.meta.yml"),
|
|
85
|
+
YAML.dump(metadata)
|
|
86
|
+
)
|
|
87
|
+
plan_path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def resolve_from_pointer
|
|
93
|
+
pointer = latest_pointer_path
|
|
94
|
+
return nil unless File.exist?(pointer)
|
|
95
|
+
|
|
96
|
+
if File.symlink?(pointer)
|
|
97
|
+
target = File.expand_path(File.readlink(pointer), cache_dir)
|
|
98
|
+
return target if valid_plan_file?(target)
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
target_ref = File.read(pointer).strip
|
|
103
|
+
return nil if target_ref.empty?
|
|
104
|
+
|
|
105
|
+
target = if target_ref.start_with?("/")
|
|
106
|
+
target_ref
|
|
107
|
+
else
|
|
108
|
+
File.expand_path(target_ref, cache_dir)
|
|
109
|
+
end
|
|
110
|
+
return target if valid_plan_file?(target)
|
|
111
|
+
|
|
112
|
+
nil
|
|
113
|
+
rescue Errno::ENOENT
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def plan_files
|
|
118
|
+
return [] unless Dir.exist?(cache_dir)
|
|
119
|
+
|
|
120
|
+
Dir.glob(File.join(cache_dir, "*-plan.md"))
|
|
121
|
+
.select { |path| valid_plan_file?(path) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def valid_plan_file?(path)
|
|
125
|
+
File.file?(path) && path.end_with?("-plan.md")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def read_metadata(plan_path)
|
|
129
|
+
content = File.read(plan_path)
|
|
130
|
+
frontmatter, = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
131
|
+
frontmatter.is_a?(Hash) ? frontmatter : nil
|
|
132
|
+
rescue
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fresh_task_file?(metadata, task_file)
|
|
137
|
+
task_meta = metadata["task_file"]
|
|
138
|
+
return false unless task_meta.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
tracked_path = resolve_tracked_path(task_meta["path"])
|
|
141
|
+
tracked_mtime = task_meta["mtime"].to_i
|
|
142
|
+
return false unless tracked_path == File.expand_path(task_file)
|
|
143
|
+
return false unless File.exist?(tracked_path)
|
|
144
|
+
|
|
145
|
+
File.mtime(tracked_path).to_i == tracked_mtime
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fresh_context_files?(metadata)
|
|
149
|
+
context_files = Array(metadata["context_files"])
|
|
150
|
+
return true if context_files.empty?
|
|
151
|
+
|
|
152
|
+
context_files.all? do |ctx|
|
|
153
|
+
next false unless ctx.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
tracked_path = resolve_tracked_path(ctx["path"])
|
|
156
|
+
tracked_mtime = ctx["mtime"].to_i
|
|
157
|
+
File.exist?(tracked_path) && File.mtime(tracked_path).to_i == tracked_mtime
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_tracked_path(path)
|
|
162
|
+
return "" if path.nil? || path.to_s.empty?
|
|
163
|
+
return path if path.start_with?("/")
|
|
164
|
+
|
|
165
|
+
File.expand_path(path, Dir.pwd)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def relative_path(path)
|
|
169
|
+
Ace::Task::Molecules::PathUtils.relative_path(path)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
MAX_UNIQUE_ATTEMPTS = 10
|
|
173
|
+
|
|
174
|
+
def build_unique_plan_path
|
|
175
|
+
base_id = Ace::B36ts.encode(Time.now.utc, format: :"2sec")
|
|
176
|
+
MAX_UNIQUE_ATTEMPTS.times do |attempt|
|
|
177
|
+
suffix = attempt.zero? ? "" : "-#{attempt}"
|
|
178
|
+
path = File.join(cache_dir, "#{base_id}#{suffix}-plan.md")
|
|
179
|
+
return path unless File.exist?(path)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
raise Ace::Support::Cli::Error.new(
|
|
183
|
+
"Failed to generate unique plan path after #{MAX_UNIQUE_ATTEMPTS} attempts. " \
|
|
184
|
+
"Clear .ace-local/task/#{@task_id}/ and retry."
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/core"
|
|
4
|
+
require_relative "path_utils"
|
|
5
|
+
require_relative "task_plan_prompt_builder"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Task
|
|
9
|
+
module Molecules
|
|
10
|
+
# Generates implementation plans from task specs using ace-llm backend.
|
|
11
|
+
class TaskPlanGenerator
|
|
12
|
+
DEFAULT_TIMEOUT = 600
|
|
13
|
+
|
|
14
|
+
# After generate(), contains { system_file:, prompt_file: } if file-based prompts were used
|
|
15
|
+
attr_reader :prompt_paths
|
|
16
|
+
|
|
17
|
+
def initialize(model:, timeout: DEFAULT_TIMEOUT, client: nil, cli_args: nil)
|
|
18
|
+
@model = model
|
|
19
|
+
@timeout = timeout
|
|
20
|
+
@client = client
|
|
21
|
+
@cli_args = cli_args
|
|
22
|
+
@prompt_paths = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def generate(task:, context_files:, cache_dir: nil)
|
|
26
|
+
if cache_dir
|
|
27
|
+
generate_with_file_prompts(task, cache_dir)
|
|
28
|
+
else
|
|
29
|
+
generate_with_inline_prompt(task, context_files)
|
|
30
|
+
end
|
|
31
|
+
rescue LoadError => e
|
|
32
|
+
raise Ace::Support::Cli::Error.new(
|
|
33
|
+
"Plan generation backend unavailable: #{e.message}. " \
|
|
34
|
+
"Ensure ace-llm is installed/configured, or provide --model."
|
|
35
|
+
)
|
|
36
|
+
rescue Ace::Support::Cli::Error
|
|
37
|
+
raise
|
|
38
|
+
rescue RuntimeError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Timeout::Error => e
|
|
39
|
+
raise Ace::Support::Cli::Error.new(
|
|
40
|
+
"Plan generation failed: #{e.message}. Retry with --refresh or choose a working --model."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def generate_with_file_prompts(task, cache_dir)
|
|
47
|
+
builder = TaskPlanPromptBuilder.new(
|
|
48
|
+
task: task,
|
|
49
|
+
cache_dir: cache_dir
|
|
50
|
+
)
|
|
51
|
+
paths = builder.build
|
|
52
|
+
@prompt_paths = paths
|
|
53
|
+
|
|
54
|
+
response = llm_client.query(
|
|
55
|
+
@model,
|
|
56
|
+
File.read(paths[:prompt_file]),
|
|
57
|
+
system: File.read(paths[:system_file]),
|
|
58
|
+
timeout: @timeout,
|
|
59
|
+
fallback: false,
|
|
60
|
+
cli_args: @cli_args
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
extract_text(response)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def generate_with_inline_prompt(task, context_files)
|
|
67
|
+
prompt = build_prompt(task, context_files)
|
|
68
|
+
response = llm_client.query(
|
|
69
|
+
@model,
|
|
70
|
+
prompt,
|
|
71
|
+
system: nil,
|
|
72
|
+
timeout: @timeout,
|
|
73
|
+
fallback: false,
|
|
74
|
+
cli_args: @cli_args
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
extract_text(response)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_text(response)
|
|
81
|
+
text = response[:text].to_s.strip
|
|
82
|
+
if text.empty?
|
|
83
|
+
raise Ace::Support::Cli::Error.new(
|
|
84
|
+
"Plan generation failed: backend returned empty output. Retry with --refresh or --model."
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
text
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_prompt(task, context_files)
|
|
92
|
+
task_content = File.read(task.file_path)
|
|
93
|
+
context_section = if context_files.empty?
|
|
94
|
+
"No additional context files were captured for this task."
|
|
95
|
+
else
|
|
96
|
+
context_files.map { |path| "- #{relative_path(path)}" }.join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
<<~PROMPT
|
|
100
|
+
Create a concrete implementation plan for task #{task.id}.
|
|
101
|
+
|
|
102
|
+
Requirements:
|
|
103
|
+
- Plan against the behavioral spec structure.
|
|
104
|
+
- Explicitly cover: Interface Contract, Error Handling, Edge Cases, Success Criteria.
|
|
105
|
+
- Cover operating modes when relevant: dry-run, force/refresh, verbose, quiet.
|
|
106
|
+
- If details are missing, include a "Behavioral Gaps" section (do not invent hidden assumptions).
|
|
107
|
+
- Include concrete acceptance checks and validation commands.
|
|
108
|
+
- Keep output as markdown only.
|
|
109
|
+
|
|
110
|
+
Output format — anchored checklist:
|
|
111
|
+
- Each step must have a stable ID (e.g., S01, S02).
|
|
112
|
+
- Include `path:line` anchors for file locations where changes apply.
|
|
113
|
+
- List dependencies between steps explicitly.
|
|
114
|
+
- Include per-step verification commands.
|
|
115
|
+
- End with a freshness summary listing input files and their expected state.
|
|
116
|
+
|
|
117
|
+
Task spec path:
|
|
118
|
+
#{relative_path(task.file_path)}
|
|
119
|
+
|
|
120
|
+
Captured context files:
|
|
121
|
+
#{context_section}
|
|
122
|
+
|
|
123
|
+
Task spec content:
|
|
124
|
+
#{task_content}
|
|
125
|
+
PROMPT
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def relative_path(path)
|
|
129
|
+
Ace::Task::Molecules::PathUtils.relative_path(path)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def llm_client
|
|
133
|
+
return @client if @client
|
|
134
|
+
|
|
135
|
+
require "ace/llm"
|
|
136
|
+
Ace::LLM::QueryInterface
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "ace/b36ts"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Task
|
|
9
|
+
module Molecules
|
|
10
|
+
# Assembles and persists prompt files for task plan generation.
|
|
11
|
+
# System prompt = project context + task/plan workflow (composed via ace-bundle).
|
|
12
|
+
# User prompt = ace-bundle <task.s.md> (task spec + bundle: frontmatter context).
|
|
13
|
+
# Config files saved alongside output for debugging/introspection.
|
|
14
|
+
# Files stored in .ace-local/task/<task-id>/prompts/.
|
|
15
|
+
class TaskPlanPromptBuilder
|
|
16
|
+
def initialize(task:, cache_dir:)
|
|
17
|
+
@task = task
|
|
18
|
+
@cache_dir = cache_dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns { system_file: path, prompt_file: path }
|
|
22
|
+
def build
|
|
23
|
+
FileUtils.mkdir_p(prompts_dir)
|
|
24
|
+
timestamp = Ace::B36ts.encode(Time.now.utc, format: :"2sec")
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
system_file: build_system_prompt(timestamp),
|
|
28
|
+
prompt_file: build_user_prompt(timestamp)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def prompts_dir
|
|
35
|
+
File.join(@cache_dir, "prompts")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_system_prompt(timestamp)
|
|
39
|
+
config_path = File.join(prompts_dir, "#{timestamp}-system.config.md")
|
|
40
|
+
output_path = File.join(prompts_dir, "#{timestamp}-system.md")
|
|
41
|
+
write_system_config(config_path)
|
|
42
|
+
run_ace_bundle(config_path, output_path)
|
|
43
|
+
output_path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_user_prompt(timestamp)
|
|
47
|
+
config_path = File.join(prompts_dir, "#{timestamp}-user.config.md")
|
|
48
|
+
output_path = File.join(prompts_dir, "#{timestamp}-user.md")
|
|
49
|
+
FileUtils.cp(@task.file_path, config_path)
|
|
50
|
+
run_ace_bundle(@task.file_path, output_path)
|
|
51
|
+
output_path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write_system_config(path)
|
|
55
|
+
File.write(path, <<~CONFIG)
|
|
56
|
+
---
|
|
57
|
+
bundle:
|
|
58
|
+
params:
|
|
59
|
+
format: markdown-xml
|
|
60
|
+
base: tmpl://agent/plan-mode
|
|
61
|
+
sections:
|
|
62
|
+
workflow:
|
|
63
|
+
title: Planning Workflow
|
|
64
|
+
files:
|
|
65
|
+
- wfi://task/plan
|
|
66
|
+
project_context:
|
|
67
|
+
title: Project Context
|
|
68
|
+
presets:
|
|
69
|
+
- project
|
|
70
|
+
repeat_instruction:
|
|
71
|
+
title: Plan Mode Reminder
|
|
72
|
+
files:
|
|
73
|
+
- tmpl://agent/plan-mode
|
|
74
|
+
---
|
|
75
|
+
CONFIG
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run_ace_bundle(input, output_path)
|
|
79
|
+
_stdout, status = Open3.capture2(
|
|
80
|
+
"ace-bundle", input, "--format", "markdown-xml", "--output", output_path
|
|
81
|
+
)
|
|
82
|
+
unless status.success?
|
|
83
|
+
raise Ace::Support::Cli::Error.new("ace-bundle failed for: #{input}")
|
|
84
|
+
end
|
|
85
|
+
rescue Errno::ENOENT
|
|
86
|
+
raise Ace::Support::Cli::Error.new("ace-bundle not found (required for prompt building)")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../atoms/task_id_formatter"
|
|
5
|
+
require_relative "task_loader"
|
|
6
|
+
require_relative "subtask_creator"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Task
|
|
10
|
+
module Molecules
|
|
11
|
+
# Reparents tasks: promote subtask to standalone, convert to orchestrator,
|
|
12
|
+
# or demote standalone to subtask of another task.
|
|
13
|
+
class TaskReparenter
|
|
14
|
+
# @param root_dir [String] Root directory for tasks
|
|
15
|
+
# @param config [Hash] Configuration hash
|
|
16
|
+
def initialize(root_dir:, config: {})
|
|
17
|
+
@root_dir = root_dir
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Reparent a task based on the target value.
|
|
22
|
+
#
|
|
23
|
+
# @param task [Models::Task] The task to reparent
|
|
24
|
+
# @param target [String] "none" (promote), "self" (orchestrator), or a parent ref
|
|
25
|
+
# @param resolve_ref [Proc] Callable that resolves a ref to a Task (for "<ref>" case)
|
|
26
|
+
# @return [Models::Task] The reparented task at its new location
|
|
27
|
+
def reparent(task, target:, resolve_ref:)
|
|
28
|
+
case target.downcase
|
|
29
|
+
when "none"
|
|
30
|
+
promote_to_standalone(task)
|
|
31
|
+
when "self"
|
|
32
|
+
convert_to_orchestrator(task)
|
|
33
|
+
else
|
|
34
|
+
demote_to_subtask(task, parent_ref: target, resolve_ref: resolve_ref)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Promote a subtask to a standalone task at root level.
|
|
41
|
+
# Moves the folder to root, strips parent from frontmatter, assigns new standalone ID.
|
|
42
|
+
def promote_to_standalone(task)
|
|
43
|
+
unless task.subtask?
|
|
44
|
+
raise ArgumentError, "Task #{task.id} is already a standalone task"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Generate new standalone ID preserving the original timestamp
|
|
48
|
+
raw_b36ts = Atoms::TaskIdFormatter.reconstruct(base_task_id(task.id))
|
|
49
|
+
new_item_id = Atoms::TaskIdFormatter.format(raw_b36ts)
|
|
50
|
+
new_id = new_item_id.formatted_id
|
|
51
|
+
|
|
52
|
+
# Extract slug from current folder name
|
|
53
|
+
slug = extract_slug(task.path, task.id)
|
|
54
|
+
|
|
55
|
+
# Build new folder/file names
|
|
56
|
+
new_folder_name = Atoms::TaskIdFormatter.folder_name(new_id, slug)
|
|
57
|
+
new_dir = File.join(@root_dir, new_folder_name)
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "Destination already exists: #{new_dir}" if File.exist?(new_dir)
|
|
60
|
+
|
|
61
|
+
# Move the folder
|
|
62
|
+
FileUtils.mv(task.path, new_dir)
|
|
63
|
+
|
|
64
|
+
# Rename spec file and update frontmatter
|
|
65
|
+
old_spec_basename = File.basename(task.file_path)
|
|
66
|
+
new_spec_basename = "#{new_folder_name}.s.md"
|
|
67
|
+
old_spec_in_new_dir = File.join(new_dir, old_spec_basename)
|
|
68
|
+
new_spec_path = File.join(new_dir, new_spec_basename)
|
|
69
|
+
|
|
70
|
+
File.rename(old_spec_in_new_dir, new_spec_path) if old_spec_basename != new_spec_basename
|
|
71
|
+
|
|
72
|
+
# Update frontmatter: change ID, remove parent
|
|
73
|
+
update_frontmatter(new_spec_path) do |fm|
|
|
74
|
+
fm["id"] = new_id
|
|
75
|
+
fm.delete("parent")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
loader = TaskLoader.new
|
|
79
|
+
loader.load(new_dir, id: new_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Convert a standalone task into an orchestrator with itself as first subtask.
|
|
83
|
+
# Creates orchestrator spec in current dir, renames current spec to subtask .a.
|
|
84
|
+
def convert_to_orchestrator(task)
|
|
85
|
+
if task.subtask?
|
|
86
|
+
raise ArgumentError, "Cannot convert subtask #{task.id} to orchestrator — promote first"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
slug = extract_slug(task.path, task.id)
|
|
90
|
+
|
|
91
|
+
# Create subtask ID using first subtask char (0)
|
|
92
|
+
next_char = SubtaskCreator::SUBTASK_CHARS[0]
|
|
93
|
+
subtask_id = "#{task.id}.#{next_char}"
|
|
94
|
+
subtask_folder_name = "#{next_char}-#{slug}"
|
|
95
|
+
subtask_dir = File.join(task.path, subtask_folder_name)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "Subtask directory already exists: #{subtask_dir}" if File.exist?(subtask_dir)
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(subtask_dir)
|
|
100
|
+
|
|
101
|
+
# Move current spec file into subtask dir with renamed filename
|
|
102
|
+
subtask_spec_name = "#{subtask_id}-#{slug}.s.md"
|
|
103
|
+
subtask_spec_path = File.join(subtask_dir, subtask_spec_name)
|
|
104
|
+
FileUtils.mv(task.file_path, subtask_spec_path)
|
|
105
|
+
|
|
106
|
+
# Update subtask frontmatter: new ID, add parent
|
|
107
|
+
update_frontmatter(subtask_spec_path) do |fm|
|
|
108
|
+
fm["id"] = subtask_id
|
|
109
|
+
fm["parent"] = task.id
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Create orchestrator spec file
|
|
113
|
+
orch_spec_name = "#{File.basename(task.path)}.s.md"
|
|
114
|
+
orch_spec_path = File.join(task.path, orch_spec_name)
|
|
115
|
+
|
|
116
|
+
orch_frontmatter = Atoms::TaskFrontmatterDefaults.build(
|
|
117
|
+
id: task.id,
|
|
118
|
+
status: task.status,
|
|
119
|
+
priority: task.priority,
|
|
120
|
+
tags: task.tags
|
|
121
|
+
)
|
|
122
|
+
orch_content = Ace::Support::Items::Atoms::FrontmatterSerializer.serialize(orch_frontmatter)
|
|
123
|
+
File.write(orch_spec_path, "#{orch_content}\n\n# #{task.title}\n")
|
|
124
|
+
|
|
125
|
+
loader = TaskLoader.new
|
|
126
|
+
loader.load(task.path, id: task.id)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Demote a standalone task to a subtask of another parent.
|
|
130
|
+
def demote_to_subtask(task, parent_ref:, resolve_ref:)
|
|
131
|
+
parent_task = resolve_ref.call(parent_ref)
|
|
132
|
+
raise ArgumentError, "Parent task '#{parent_ref}' not found" unless parent_task
|
|
133
|
+
|
|
134
|
+
if task.id == parent_task.id
|
|
135
|
+
raise ArgumentError, "Cannot reparent task to itself"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Allocate next subtask char
|
|
139
|
+
existing_chars = scan_existing_subtask_chars(parent_task.path, parent_task.id)
|
|
140
|
+
next_char = allocate_next_char(existing_chars)
|
|
141
|
+
|
|
142
|
+
# Build new subtask ID
|
|
143
|
+
new_id = "#{parent_task.id}.#{next_char}"
|
|
144
|
+
slug = extract_slug(task.path, task.id)
|
|
145
|
+
new_folder_name = "#{next_char}-#{slug}"
|
|
146
|
+
new_dir = File.join(parent_task.path, new_folder_name)
|
|
147
|
+
|
|
148
|
+
raise ArgumentError, "Destination already exists: #{new_dir}" if File.exist?(new_dir)
|
|
149
|
+
|
|
150
|
+
# Move folder into parent
|
|
151
|
+
FileUtils.mv(task.path, new_dir)
|
|
152
|
+
|
|
153
|
+
# Rename spec file
|
|
154
|
+
old_spec_basename = File.basename(task.file_path)
|
|
155
|
+
new_spec_basename = "#{new_id}-#{slug}.s.md"
|
|
156
|
+
old_spec_in_new_dir = File.join(new_dir, old_spec_basename)
|
|
157
|
+
new_spec_path = File.join(new_dir, new_spec_basename)
|
|
158
|
+
|
|
159
|
+
File.rename(old_spec_in_new_dir, new_spec_path) if old_spec_basename != new_spec_basename
|
|
160
|
+
|
|
161
|
+
# Update frontmatter: new ID, set parent
|
|
162
|
+
update_frontmatter(new_spec_path) do |fm|
|
|
163
|
+
fm["id"] = new_id
|
|
164
|
+
fm["parent"] = parent_task.id
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
loader = TaskLoader.new
|
|
168
|
+
loader.load(new_dir, id: new_id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Extract the base task ID (without subtask char) from a potentially dotted ID
|
|
172
|
+
# "8pp.t.q7w.a" => "8pp.t.q7w"
|
|
173
|
+
def base_task_id(id)
|
|
174
|
+
parts = id.split(".")
|
|
175
|
+
if parts.length > 3
|
|
176
|
+
parts[0..2].join(".")
|
|
177
|
+
else
|
|
178
|
+
id
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Extract slug from folder path and task ID.
|
|
183
|
+
def extract_slug(path, id)
|
|
184
|
+
folder = File.basename(path)
|
|
185
|
+
|
|
186
|
+
# Try full ID prefix first: "8pp.t.q7w.0-slug" or "8pp.t.q7w-slug"
|
|
187
|
+
prefix = "#{id}-"
|
|
188
|
+
return folder[prefix.length..] if folder.start_with?(prefix)
|
|
189
|
+
|
|
190
|
+
# Try short format: "0-slug" (subtask char + dash + slug)
|
|
191
|
+
parts = id.split(".")
|
|
192
|
+
if parts.length > 3
|
|
193
|
+
char_prefix = "#{parts.last}-"
|
|
194
|
+
return folder[char_prefix.length..] if folder.start_with?(char_prefix)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
folder
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Update frontmatter in a spec file, yielding the hash for modification.
|
|
201
|
+
def update_frontmatter(file_path)
|
|
202
|
+
content = File.read(file_path)
|
|
203
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
204
|
+
body = body.sub(/\A\n/, "")
|
|
205
|
+
|
|
206
|
+
yield frontmatter
|
|
207
|
+
|
|
208
|
+
new_content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body)
|
|
209
|
+
tmp_path = "#{file_path}.tmp.#{Process.pid}"
|
|
210
|
+
File.write(tmp_path, new_content)
|
|
211
|
+
File.rename(tmp_path, file_path)
|
|
212
|
+
rescue
|
|
213
|
+
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
214
|
+
raise
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Reuse subtask char allocation logic from SubtaskCreator.
|
|
218
|
+
def scan_existing_subtask_chars(parent_dir, _parent_id)
|
|
219
|
+
chars = []
|
|
220
|
+
return chars unless Dir.exist?(parent_dir)
|
|
221
|
+
|
|
222
|
+
Dir.entries(parent_dir).sort.each do |entry|
|
|
223
|
+
next if entry.start_with?(".")
|
|
224
|
+
|
|
225
|
+
full_path = File.join(parent_dir, entry)
|
|
226
|
+
next unless File.directory?(full_path)
|
|
227
|
+
|
|
228
|
+
# Short format: "0-slug" or "a-slug"
|
|
229
|
+
if (short_match = entry.match(/^([a-z0-9])-/))
|
|
230
|
+
chars << short_match[1]
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
chars
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def allocate_next_char(existing_chars)
|
|
238
|
+
SubtaskCreator::SUBTASK_CHARS.each do |char|
|
|
239
|
+
return char unless existing_chars.include?(char)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
raise RangeError, "Maximum number of subtasks (#{SubtaskCreator::MAX_SUBTASKS}) exceeded"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|