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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Task
7
+ module CLI
8
+ module Commands
9
+ # ace-support-cli Command class for ace-task show
10
+ class Show < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc <<~DESC.strip
14
+ Show task details
15
+
16
+ Displays a task by reference (full ID like 8pp.t.q7w, short t.q7w, or suffix q7w).
17
+ DESC
18
+
19
+ example [
20
+ "q7w # Show by suffix shortcut",
21
+ "8pp.t.q7w # Show by full ID",
22
+ "t.q7w # Show by short reference",
23
+ "q7w --path # Print file path only",
24
+ "q7w --content # Print raw markdown content",
25
+ "q7w --tree # Show parent + subtask tree"
26
+ ]
27
+
28
+ argument :ref, required: true, desc: "Task reference (full ID, short ref, or suffix)"
29
+
30
+ option :path, type: :boolean, desc: "Print file path only"
31
+ option :content, type: :boolean, desc: "Print raw markdown content"
32
+ option :tree, type: :boolean, desc: "Show parent + subtask tree view"
33
+
34
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
35
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
36
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
37
+
38
+ def call(ref:, **options)
39
+ manager = Ace::Task::Organisms::TaskManager.new
40
+ task = manager.show(ref)
41
+
42
+ unless task
43
+ raise Ace::Support::Cli::Error.new("Task '#{ref}' not found")
44
+ end
45
+
46
+ if options[:path]
47
+ puts task.file_path
48
+ elsif options[:content]
49
+ puts File.read(task.file_path)
50
+ else
51
+ puts Molecules::TaskDisplayFormatter.format(task, show_content: false)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Task
7
+ module CLI
8
+ module Commands
9
+ # ace-support-cli Command class for ace-task status
10
+ class Status < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc <<~DESC.strip
14
+ Show task status overview
15
+
16
+ Displays up-next tasks, summary stats, and recently completed tasks.
17
+ DESC
18
+
19
+ example [
20
+ " # Default status view",
21
+ "--up-next-limit 5 # Show 5 up-next tasks",
22
+ "--recently-done-limit 3 # Show 3 recently done tasks"
23
+ ]
24
+
25
+ option :up_next_limit, type: :integer, desc: "Max up-next tasks to show"
26
+ option :recently_done_limit, type: :integer, desc: "Max recently-done tasks to show"
27
+
28
+ def call(**options)
29
+ manager = Ace::Task::Organisms::TaskManager.new
30
+ all_tasks = manager.list(in_folder: "all")
31
+
32
+ config = Ace::Task::Molecules::TaskConfigLoader.load
33
+ limits = resolve_limits(config, options)
34
+
35
+ score_fn = ->(task) do
36
+ ssc = Ace::Support::Items::Atoms::SortScoreCalculator
37
+ weight = ssc.priority_weight(task.priority)
38
+ age = task.created_at ? [(Time.now - task.created_at) / 86_400.0, 0].max : 0
39
+ ssc.compute(priority_weight: weight, age_days: age, status: task.status)
40
+ end
41
+
42
+ smart_sorter = ->(items) do
43
+ Ace::Support::Items::Molecules::SmartSorter.sort(
44
+ items,
45
+ score_fn: score_fn,
46
+ pin_accessor: ->(t) { t.metadata&.dig("position") }
47
+ )
48
+ end
49
+
50
+ categorized = Ace::Support::Items::Molecules::StatusCategorizer.categorize(
51
+ all_tasks,
52
+ up_next_limit: limits[:up_next],
53
+ recently_done_limit: limits[:recently_done],
54
+ pending_statuses: %w[pending],
55
+ done_statuses: %w[done],
56
+ up_next_sorter: smart_sorter
57
+ )
58
+
59
+ puts Ace::Task::Molecules::TaskDisplayFormatter.format_status(
60
+ categorized, all_tasks: all_tasks
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ def resolve_limits(config, options)
67
+ status_config = config.dig("task", "status") || {}
68
+ {
69
+ up_next: (options[:up_next_limit] || status_config["up_next_limit"] || 3).to_i,
70
+ recently_done: (options[:recently_done_limit] || status_config["recently_done_limit"] || 9).to_i
71
+ }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/support/items"
5
+
6
+ module Ace
7
+ module Task
8
+ module CLI
9
+ module Commands
10
+ # ace-support-cli Command class for ace-task update
11
+ class Update < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc <<~DESC.strip
15
+ Update task metadata and/or move to a folder
16
+
17
+ Updates frontmatter fields using set, add, or remove operations.
18
+ Use --set for scalar fields, --add/--remove for array fields like tags.
19
+ Use --move-to to relocate to a special folder or back to root.
20
+ DESC
21
+
22
+ example [
23
+ "q7w --set status=done",
24
+ "q7w --set status=done,priority=high",
25
+ "q7w --add tags=shipped --remove tags=pending-review",
26
+ "q7w --set worktree.branch=my-branch",
27
+ "q7w --set status=done --move-to archive",
28
+ "q7w --move-to next",
29
+ "q7w.a --move-as-child-of none # Promote subtask to standalone",
30
+ "q7w --move-as-child-of self # Convert to orchestrator",
31
+ "q7w --move-as-child-of abc # Demote to subtask of abc",
32
+ "q7w --position first # Pin to sort before all tasks",
33
+ "q7w --position last # Pin to sort after existing tasks",
34
+ "q7w --position after:abc # Pin to sort after task abc",
35
+ "q7w --remove position # Remove pin, return to auto-sort"
36
+ ]
37
+
38
+ argument :ref, required: true, desc: "Task reference (full ID, short ref, or suffix)"
39
+
40
+ option :set, type: :array, desc: "Set field: key=value (comma-separated for multiple)"
41
+ option :add, type: :array, desc: "Add to array field: key=value (comma-separated for multiple)"
42
+ option :remove, type: :array, desc: "Remove from array field: key=value (comma-separated for multiple)"
43
+ option :move_to, type: :string, aliases: %w[-m], desc: "Move to folder (archive, maybe, anytime, next)"
44
+ option :move_as_child_of, type: :string, desc: "Reparent: <parent_ref>, 'none' (promote), 'self' (orchestrator)"
45
+ option :position, type: :string, aliases: %w[-p], desc: "Set position: first, last, after:<ref>, before:<ref>"
46
+
47
+ option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
48
+
49
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
50
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
51
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
52
+
53
+ def call(ref:, **options)
54
+ set_args = Array(options[:set])
55
+ add_args = Array(options[:add])
56
+ remove_args = Array(options[:remove])
57
+ move_to = options[:move_to]
58
+ move_as_child = options[:move_as_child_of]
59
+ position_arg = options[:position]
60
+
61
+ has_any_op = !set_args.empty? || !add_args.empty? || !remove_args.empty? ||
62
+ move_to || move_as_child || position_arg
63
+ unless has_any_op
64
+ warn "Error: at least one of --set, --add, --remove, --move-to, --move-as-child-of, or --position is required"
65
+ warn ""
66
+ warn "Usage: ace-task update REF [--set K=V]... [--move-to FOLDER] [--position first|last|after:REF|before:REF]"
67
+ raise Ace::Support::Cli::Error.new("No update operations specified")
68
+ end
69
+
70
+ if move_to && move_as_child
71
+ raise Ace::Support::Cli::Error.new("Cannot use --move-to and --move-as-child-of together")
72
+ end
73
+
74
+ set_hash = parse_kv_pairs(set_args)
75
+ add_hash = parse_kv_pairs(add_args)
76
+ remove_hash = parse_kv_pairs(remove_args)
77
+
78
+ manager = Ace::Task::Organisms::TaskManager.new
79
+
80
+ # Resolve --position into a set or remove operation
81
+ if position_arg
82
+ pos_value = resolve_position(position_arg, manager)
83
+ set_hash["position"] = pos_value
84
+ end
85
+
86
+ task = manager.update(ref, set: set_hash, add: add_hash, remove: remove_hash,
87
+ move_to: move_to, move_as_child_of: move_as_child)
88
+
89
+ unless task
90
+ raise Ace::Support::Cli::Error.new("Task '#{ref}' not found")
91
+ end
92
+
93
+ if move_as_child
94
+ puts "Task reparented: #{task.id} #{task.title}"
95
+ elsif move_to
96
+ folder_info = task.special_folder || "root"
97
+ puts "Task updated: #{task.id} #{task.title} → #{folder_info}"
98
+ else
99
+ puts "Task updated: #{task.id} #{task.title}"
100
+ end
101
+ puts "Info: #{manager.last_update_note}" if manager.last_update_note
102
+
103
+ if options[:git_commit]
104
+ commit_paths = (move_to || move_as_child) ? [manager.root_dir] : [task.path]
105
+ intention = if move_as_child
106
+ "reparent task #{task.id}"
107
+ elsif move_to
108
+ "update task #{task.id} and move to #{task.special_folder || "root"}"
109
+ else
110
+ "update task #{task.id}"
111
+ end
112
+ Ace::Support::Items::Molecules::GitCommitter.commit(
113
+ paths: commit_paths,
114
+ intention: intention
115
+ )
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # Resolve a --position argument into a B36TS value.
122
+ # Supports: first, last, after:<ref>, before:<ref>
123
+ def resolve_position(arg, manager)
124
+ pg = Ace::Support::Items::Atoms::PositionGenerator
125
+
126
+ case arg
127
+ when "first"
128
+ pg.first
129
+ when "last"
130
+ pg.last
131
+ when /\Aafter:(.+)\z/
132
+ target = manager.show($1)
133
+ raise Ace::Support::Cli::Error.new("Task '#{$1}' not found for position reference") unless target
134
+
135
+ target_pos = target.metadata&.dig("position")
136
+ if target_pos
137
+ pg.after(target_pos)
138
+ else
139
+ # Target has no position — generate a current timestamp
140
+ pg.last
141
+ end
142
+ when /\Abefore:(.+)\z/
143
+ target = manager.show($1)
144
+ raise Ace::Support::Cli::Error.new("Task '#{$1}' not found for position reference") unless target
145
+
146
+ target_pos = target.metadata&.dig("position")
147
+ if target_pos
148
+ pg.before(target_pos)
149
+ else
150
+ # Target has no position — generate a very early timestamp
151
+ pg.first
152
+ end
153
+ else
154
+ raise Ace::Support::Cli::Error.new("Invalid --position value '#{arg}': expected first, last, after:<ref>, or before:<ref>")
155
+ end
156
+ end
157
+
158
+ # Parse ["key=value", "key=value2"] into {"key" => typed_value, ...}
159
+ def parse_kv_pairs(args)
160
+ result = {}
161
+ args.each do |arg|
162
+ unless arg.include?("=")
163
+ raise Ace::Support::Cli::Error.new("Invalid format '#{arg}': expected key=value")
164
+ end
165
+
166
+ parsed = Ace::Support::Items::Atoms::FieldArgumentParser.parse([arg])
167
+ parsed.each do |key, value|
168
+ result[key] = if result.key?(key)
169
+ Array(result[key]) + Array(value)
170
+ else
171
+ value
172
+ end
173
+ end
174
+ rescue Ace::Support::Items::Atoms::FieldArgumentParser::ParseError => e
175
+ raise Ace::Support::Cli::Error.new("Invalid argument '#{arg}': #{e.message}")
176
+ end
177
+ result
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "version"
6
+ require_relative "cli/commands/create"
7
+ require_relative "cli/commands/show"
8
+ require_relative "cli/commands/list"
9
+ require_relative "cli/commands/update"
10
+ require_relative "cli/commands/doctor"
11
+ require_relative "cli/commands/status"
12
+ require_relative "cli/commands/plan"
13
+
14
+ module Ace
15
+ module Task
16
+ # Flat CLI registry for ace-task (task management).
17
+ module TaskCLI
18
+ extend Ace::Support::Cli::RegistryDsl
19
+
20
+ PROGRAM_NAME = "ace-task"
21
+
22
+ REGISTERED_COMMANDS = [
23
+ ["create", "Create a new task"],
24
+ ["show", "Show task details"],
25
+ ["list", "List tasks"],
26
+ ["update", "Update task metadata (fields, move, reparent)"],
27
+ ["doctor", "Run health checks on tasks"],
28
+ ["status", "Show task status overview"],
29
+ ["plan", "Resolve or generate implementation plan"]
30
+ ].freeze
31
+
32
+ HELP_EXAMPLES = [
33
+ 'ace-task create "Fix login bug"',
34
+ 'ace-task create "Fix auth" --priority high --tags auth,security',
35
+ "ace-task show q7w",
36
+ "ace-task show q7w --tree",
37
+ "ace-task list --status pending",
38
+ "ace-task list --in maybe",
39
+ "ace-task update q7w --set status=done --move-to archive",
40
+ "ace-task update q7w --set status=done --set priority=high",
41
+ "ace-task update q7w --move-to next",
42
+ "ace-task doctor",
43
+ "ace-task doctor --auto-fix --dry-run",
44
+ "ace-task status",
45
+ "ace-task status --up-next-limit 5",
46
+ "ace-task plan q7w",
47
+ "ace-task plan q7w --refresh",
48
+ "ace-task plan q7w --content"
49
+ ].freeze
50
+
51
+ register "create", CLI::Commands::Create
52
+ register "show", CLI::Commands::Show
53
+ register "list", CLI::Commands::List
54
+ register "update", CLI::Commands::Update
55
+ register "doctor", CLI::Commands::Doctor
56
+ register "status", CLI::Commands::Status
57
+ register "plan", CLI::Commands::Plan
58
+
59
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
60
+ gem_name: "ace-task",
61
+ version: Ace::Task::VERSION
62
+ )
63
+ register "version", version_cmd
64
+ register "--version", version_cmd
65
+
66
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
67
+ program_name: PROGRAM_NAME,
68
+ version: Ace::Task::VERSION,
69
+ commands: REGISTERED_COMMANDS,
70
+ examples: HELP_EXAMPLES
71
+ )
72
+ register "help", help_cmd
73
+ register "--help", help_cmd
74
+ register "-h", help_cmd
75
+
76
+ # Entry point for CLI invocation
77
+ # @param args [Array<String>] Command-line arguments
78
+ def self.start(args)
79
+ Ace::Support::Cli::Runner.new(self).call(args: args)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Task
5
+ module Models
6
+ # Value object representing a task.
7
+ Task = Struct.new(
8
+ :id, # Formatted task ID (e.g., "8pp.t.q7w")
9
+ :status, # Task status (e.g., "pending", "in-progress", "done")
10
+ :title, # Human-readable title
11
+ :priority, # Priority level (e.g., "critical", "high", "medium", "low")
12
+ :estimate, # Effort estimate
13
+ :dependencies, # Array of task IDs this task depends on
14
+ :tags, # Array of tag strings
15
+ :content, # Body markdown content
16
+ :path, # Directory path
17
+ :file_path, # Full path to spec file
18
+ :special_folder, # Special folder name (e.g., "_maybe", nil if none)
19
+ :created_at, # Time decoded from ID
20
+ :subtasks, # Array of subtask Task objects (loaded from folder co-location)
21
+ :parent_id, # Parent task ID if this is a subtask
22
+ :metadata, # Extra frontmatter fields
23
+ keyword_init: true
24
+ ) do
25
+ # Last 3 characters of the ID (shortcut for resolution)
26
+ def shortcut
27
+ id[-3..] if id
28
+ end
29
+
30
+ # Whether this task is a subtask (has a parent)
31
+ def subtask?
32
+ !parent_id.nil?
33
+ end
34
+
35
+ # Whether this task has subtasks
36
+ def has_subtasks?
37
+ subtasks && !subtasks.empty?
38
+ end
39
+
40
+ def to_s
41
+ "Task(#{id}: #{title})"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Task
5
+ module Molecules
6
+ # Shared path utilities for task plan components.
7
+ module PathUtils
8
+ module_function
9
+
10
+ def relative_path(path)
11
+ absolute = File.expand_path(path)
12
+ cwd = File.expand_path(Dir.pwd)
13
+ return absolute unless absolute.start_with?("#{cwd}/")
14
+
15
+ absolute.delete_prefix("#{cwd}/")
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../atoms/task_id_formatter"
5
+ require_relative "../atoms/task_frontmatter_defaults"
6
+ require_relative "task_loader"
7
+
8
+ module Ace
9
+ module Task
10
+ module Molecules
11
+ # Creates subtasks within a parent task's folder.
12
+ # Allocates subtask characters sequentially: 0-9 then a-z (max 36 subtasks).
13
+ class SubtaskCreator
14
+ # Maximum number of subtasks per parent (0-9 = 10, a-z = 26)
15
+ MAX_SUBTASKS = 36
16
+
17
+ # Ordered sequence of subtask characters
18
+ SUBTASK_CHARS = (0..35).map { |i| i.to_s(36) }.freeze
19
+
20
+ # @param config [Hash] Configuration hash
21
+ def initialize(config: {})
22
+ @config = config
23
+ end
24
+
25
+ # Create a subtask within a parent task's folder.
26
+ #
27
+ # @param parent_task [Models::Task] Parent task
28
+ # @param title [String] Subtask title
29
+ # @param status [String, nil] Initial status
30
+ # @param priority [String, nil] Priority level
31
+ # @param tags [Array<String>] Tags
32
+ # @param time [Time] Creation time (default: now)
33
+ # @return [Models::Task] Created subtask
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)
36
+ raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
37
+
38
+ # Find next available subtask character
39
+ existing_chars = scan_existing_subtask_chars(parent_task.path, parent_task.id)
40
+ next_char = allocate_next_char(existing_chars)
41
+
42
+ # Build subtask ID: parent_id + ".{char}"
43
+ subtask_id = "#{parent_task.id}.#{next_char}"
44
+
45
+ # Generate slugs (folder: 5 words, file: 7 words)
46
+ folder_slug = generate_folder_slug(title)
47
+ file_slug = generate_file_slug(title)
48
+
49
+ # Build folder and file names
50
+ folder_name = "#{next_char}-#{folder_slug}"
51
+ subtask_dir = File.join(parent_task.path, folder_name)
52
+ FileUtils.mkdir_p(subtask_dir)
53
+
54
+ # Build frontmatter
55
+ effective_status = status || @config.dig("task", "default_status") || "pending"
56
+ frontmatter = Atoms::TaskFrontmatterDefaults.build(
57
+ id: subtask_id,
58
+ status: effective_status,
59
+ priority: priority,
60
+ tags: tags,
61
+ created_at: time,
62
+ parent: parent_task.id,
63
+ estimate: estimate
64
+ )
65
+
66
+ # Write spec file
67
+ spec_filename = "#{subtask_id}-#{file_slug}.s.md"
68
+ spec_file = File.join(subtask_dir, spec_filename)
69
+ content = build_spec_content(frontmatter: frontmatter, title: title)
70
+ File.write(spec_file, content)
71
+
72
+ # Load and return
73
+ loader = TaskLoader.new
74
+ loader.load(subtask_dir, id: subtask_id, load_subtasks: false)
75
+ end
76
+
77
+ private
78
+
79
+ # Scan parent directory for existing subtask folders and extract their chars.
80
+ def scan_existing_subtask_chars(parent_dir, _parent_id)
81
+ chars = []
82
+ return chars unless Dir.exist?(parent_dir)
83
+
84
+ Dir.entries(parent_dir).sort.each do |entry|
85
+ next if entry.start_with?(".")
86
+
87
+ full_path = File.join(parent_dir, entry)
88
+ next unless File.directory?(full_path)
89
+
90
+ # Short format: "0-slug" or "a-slug"
91
+ if (short_match = entry.match(/^([a-z0-9])-/))
92
+ chars << short_match[1]
93
+ end
94
+ end
95
+
96
+ chars
97
+ end
98
+
99
+ # Allocate the next available subtask character.
100
+ # @raise [RangeError] If all 36 chars are used
101
+ def allocate_next_char(existing_chars)
102
+ SUBTASK_CHARS.each do |char|
103
+ return char unless existing_chars.include?(char)
104
+ end
105
+
106
+ raise RangeError, "Maximum number of subtasks (#{MAX_SUBTASKS}) exceeded"
107
+ end
108
+
109
+ def generate_folder_slug(title)
110
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title)
111
+ words = sanitized.split("-")
112
+ result = words.take(5).join("-")
113
+ result.empty? ? "subtask" : result
114
+ end
115
+
116
+ def generate_file_slug(title)
117
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title)
118
+ words = sanitized.split("-")
119
+ result = words.take(7).join("-")
120
+ result.empty? ? "subtask" : result
121
+ end
122
+
123
+ def build_spec_content(frontmatter:, title:)
124
+ serialized = Ace::Support::Items::Atoms::FrontmatterSerializer.serialize(frontmatter)
125
+ "#{serialized}\n\n# #{title}\n"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/fs"
5
+
6
+ module Ace
7
+ module Task
8
+ module Molecules
9
+ # Loads and merges configuration for ace-task from the cascade:
10
+ # .ace-defaults/task/config.yml (gem) -> ~/.ace/task/config.yml (user) -> .ace/task/config.yml (project)
11
+ class TaskConfigLoader
12
+ DEFAULT_ROOT_DIR = ".ace-tasks"
13
+
14
+ # Load configuration with cascade merge
15
+ # @param gem_root [String] Path to the ace-task gem root
16
+ # @return [Hash] Merged configuration
17
+ def self.load(gem_root: nil)
18
+ gem_root ||= File.expand_path("../../../..", __dir__)
19
+ new(gem_root: gem_root).load
20
+ end
21
+
22
+ def initialize(gem_root:)
23
+ @gem_root = gem_root
24
+ end
25
+
26
+ # Load and merge configuration
27
+ # @return [Hash] Merged configuration
28
+ def load
29
+ config = load_defaults
30
+ config = deep_merge(config, load_user_config)
31
+ deep_merge(config, load_project_config)
32
+ end
33
+
34
+ # Get the root directory for tasks
35
+ # @param config [Hash] Configuration hash
36
+ # @return [String] Absolute path to tasks root directory
37
+ def self.root_dir(config = nil)
38
+ config ||= load
39
+ dir = config.dig("task", "root_dir") || DEFAULT_ROOT_DIR
40
+
41
+ if dir.start_with?("/")
42
+ dir
43
+ else
44
+ File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, dir)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def load_defaults
51
+ path = File.join(@gem_root, ".ace-defaults", "task", "config.yml")
52
+ load_yaml(path) || {}
53
+ end
54
+
55
+ def load_user_config
56
+ path = File.join(Dir.home, ".ace", "task", "config.yml")
57
+ load_yaml(path) || {}
58
+ end
59
+
60
+ def load_project_config
61
+ path = File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, ".ace", "task", "config.yml")
62
+ load_yaml(path) || {}
63
+ end
64
+
65
+ def load_yaml(path)
66
+ return nil unless File.exist?(path)
67
+
68
+ YAML.safe_load_file(path, permitted_classes: [Date, Time, Symbol])
69
+ rescue Errno::ENOENT
70
+ nil
71
+ rescue Psych::SyntaxError => e
72
+ warn "Warning: ace-task config parse error in #{path}: #{e.message}"
73
+ nil
74
+ end
75
+
76
+ def deep_merge(base, override)
77
+ return base unless override.is_a?(Hash)
78
+
79
+ result = base.dup
80
+ override.each do |key, value|
81
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
82
+ deep_merge(result[key], value)
83
+ else
84
+ value
85
+ end
86
+ end
87
+ result
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end