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,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
|
data/lib/ace/task/cli.rb
ADDED
|
@@ -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
|