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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/skill-sources/ace-task.yml +19 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-task.yml +19 -0
  4. data/.ace-defaults/task/config.yml +25 -0
  5. data/CHANGELOG.md +518 -0
  6. data/README.md +52 -0
  7. data/Rakefile +12 -0
  8. data/exe/ace-task +22 -0
  9. data/handbook/guides/task-definition.g.md +156 -0
  10. data/handbook/skills/as-bug-analyze/SKILL.md +26 -0
  11. data/handbook/skills/as-bug-fix/SKILL.md +27 -0
  12. data/handbook/skills/as-task-document-unplanned/SKILL.md +27 -0
  13. data/handbook/skills/as-task-draft/SKILL.md +24 -0
  14. data/handbook/skills/as-task-finder/SKILL.md +27 -0
  15. data/handbook/skills/as-task-plan/SKILL.md +30 -0
  16. data/handbook/skills/as-task-review/SKILL.md +25 -0
  17. data/handbook/skills/as-task-review-questions/SKILL.md +25 -0
  18. data/handbook/skills/as-task-update/SKILL.md +21 -0
  19. data/handbook/skills/as-task-work/SKILL.md +41 -0
  20. data/handbook/templates/task/draft.template.md +166 -0
  21. data/handbook/templates/task/file-modification-checklist.template.md +26 -0
  22. data/handbook/templates/task/technical-approach.template.md +26 -0
  23. data/handbook/workflow-instructions/bug/analyze.wf.md +458 -0
  24. data/handbook/workflow-instructions/bug/fix.wf.md +512 -0
  25. data/handbook/workflow-instructions/task/document-unplanned.wf.md +222 -0
  26. data/handbook/workflow-instructions/task/draft.wf.md +552 -0
  27. data/handbook/workflow-instructions/task/finder.wf.md +22 -0
  28. data/handbook/workflow-instructions/task/plan.wf.md +489 -0
  29. data/handbook/workflow-instructions/task/review-plan.wf.md +144 -0
  30. data/handbook/workflow-instructions/task/review-questions.wf.md +411 -0
  31. data/handbook/workflow-instructions/task/review-work.wf.md +146 -0
  32. data/handbook/workflow-instructions/task/review.wf.md +351 -0
  33. data/handbook/workflow-instructions/task/update.wf.md +118 -0
  34. data/handbook/workflow-instructions/task/work.wf.md +106 -0
  35. data/lib/ace/task/atoms/task_file_pattern.rb +68 -0
  36. data/lib/ace/task/atoms/task_frontmatter_defaults.rb +46 -0
  37. data/lib/ace/task/atoms/task_id_formatter.rb +62 -0
  38. data/lib/ace/task/atoms/task_validation_rules.rb +51 -0
  39. data/lib/ace/task/cli/commands/create.rb +105 -0
  40. data/lib/ace/task/cli/commands/doctor.rb +206 -0
  41. data/lib/ace/task/cli/commands/list.rb +73 -0
  42. data/lib/ace/task/cli/commands/plan.rb +119 -0
  43. data/lib/ace/task/cli/commands/show.rb +58 -0
  44. data/lib/ace/task/cli/commands/status.rb +77 -0
  45. data/lib/ace/task/cli/commands/update.rb +183 -0
  46. data/lib/ace/task/cli.rb +83 -0
  47. data/lib/ace/task/models/task.rb +46 -0
  48. data/lib/ace/task/molecules/path_utils.rb +20 -0
  49. data/lib/ace/task/molecules/subtask_creator.rb +130 -0
  50. data/lib/ace/task/molecules/task_config_loader.rb +92 -0
  51. data/lib/ace/task/molecules/task_creator.rb +115 -0
  52. data/lib/ace/task/molecules/task_display_formatter.rb +221 -0
  53. data/lib/ace/task/molecules/task_doctor_fixer.rb +510 -0
  54. data/lib/ace/task/molecules/task_doctor_reporter.rb +264 -0
  55. data/lib/ace/task/molecules/task_frontmatter_validator.rb +138 -0
  56. data/lib/ace/task/molecules/task_loader.rb +119 -0
  57. data/lib/ace/task/molecules/task_plan_cache.rb +190 -0
  58. data/lib/ace/task/molecules/task_plan_generator.rb +141 -0
  59. data/lib/ace/task/molecules/task_plan_prompt_builder.rb +91 -0
  60. data/lib/ace/task/molecules/task_reparenter.rb +247 -0
  61. data/lib/ace/task/molecules/task_resolver.rb +115 -0
  62. data/lib/ace/task/molecules/task_scanner.rb +129 -0
  63. data/lib/ace/task/molecules/task_structure_validator.rb +154 -0
  64. data/lib/ace/task/organisms/task_doctor.rb +199 -0
  65. data/lib/ace/task/organisms/task_manager.rb +353 -0
  66. data/lib/ace/task/version.rb +7 -0
  67. data/lib/ace/task.rb +37 -0
  68. 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