trak_flow 0.1.3
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/.envrc +3 -0
- data/CHANGELOG.md +69 -0
- data/COMMITS.md +196 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +281 -0
- data/README.md +479 -0
- data/Rakefile +16 -0
- data/bin/tf +6 -0
- data/bin/tf_mcp +81 -0
- data/docs/.keep +0 -0
- data/docs/api/database.md +434 -0
- data/docs/api/ruby-library.md +349 -0
- data/docs/api/task-model.md +341 -0
- data/docs/assets/stylesheets/extra.css +53 -0
- data/docs/assets/trak_flow.jpg +0 -0
- data/docs/cli/admin-commands.md +369 -0
- data/docs/cli/dependency-commands.md +321 -0
- data/docs/cli/label-commands.md +222 -0
- data/docs/cli/overview.md +163 -0
- data/docs/cli/plan-commands.md +344 -0
- data/docs/cli/task-commands.md +333 -0
- data/docs/core-concepts/dependencies.md +232 -0
- data/docs/core-concepts/labels.md +217 -0
- data/docs/core-concepts/overview.md +178 -0
- data/docs/core-concepts/plans-workflows.md +264 -0
- data/docs/core-concepts/tasks.md +205 -0
- data/docs/getting-started/configuration.md +120 -0
- data/docs/getting-started/installation.md +79 -0
- data/docs/getting-started/quick-start.md +245 -0
- data/docs/index.md +169 -0
- data/docs/mcp/integration.md +302 -0
- data/docs/mcp/overview.md +206 -0
- data/docs/mcp/resources.md +284 -0
- data/docs/mcp/tools.md +457 -0
- data/examples/basic_usage.rb +365 -0
- data/examples/cli_demo.sh +314 -0
- data/examples/mcp/Gemfile +9 -0
- data/examples/mcp/Gemfile.lock +226 -0
- data/examples/mcp/http_demo.rb +232 -0
- data/examples/mcp/stdio_demo.rb +146 -0
- data/lib/trak_flow/cli/admin_commands.rb +136 -0
- data/lib/trak_flow/cli/config_commands.rb +260 -0
- data/lib/trak_flow/cli/dep_commands.rb +71 -0
- data/lib/trak_flow/cli/label_commands.rb +76 -0
- data/lib/trak_flow/cli/main_commands.rb +386 -0
- data/lib/trak_flow/cli/plan_commands.rb +185 -0
- data/lib/trak_flow/cli/workflow_commands.rb +133 -0
- data/lib/trak_flow/cli.rb +110 -0
- data/lib/trak_flow/config/defaults.yml +114 -0
- data/lib/trak_flow/config/section.rb +74 -0
- data/lib/trak_flow/config.rb +276 -0
- data/lib/trak_flow/graph/dependency_graph.rb +288 -0
- data/lib/trak_flow/id_generator.rb +52 -0
- data/lib/trak_flow/mcp/resources/base_resource.rb +25 -0
- data/lib/trak_flow/mcp/resources/dependency_graph.rb +31 -0
- data/lib/trak_flow/mcp/resources/label_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/plan_by_id.rb +27 -0
- data/lib/trak_flow/mcp/resources/plan_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/task_by_id.rb +31 -0
- data/lib/trak_flow/mcp/resources/task_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/task_next.rb +30 -0
- data/lib/trak_flow/mcp/resources/workflow_by_id.rb +27 -0
- data/lib/trak_flow/mcp/resources/workflow_list.rb +21 -0
- data/lib/trak_flow/mcp/server.rb +140 -0
- data/lib/trak_flow/mcp/tools/base_tool.rb +29 -0
- data/lib/trak_flow/mcp/tools/comment_add.rb +33 -0
- data/lib/trak_flow/mcp/tools/dep_add.rb +34 -0
- data/lib/trak_flow/mcp/tools/dep_remove.rb +25 -0
- data/lib/trak_flow/mcp/tools/label_add.rb +28 -0
- data/lib/trak_flow/mcp/tools/label_remove.rb +25 -0
- data/lib/trak_flow/mcp/tools/plan_add_step.rb +35 -0
- data/lib/trak_flow/mcp/tools/plan_create.rb +33 -0
- data/lib/trak_flow/mcp/tools/plan_run.rb +58 -0
- data/lib/trak_flow/mcp/tools/plan_start.rb +58 -0
- data/lib/trak_flow/mcp/tools/task_block.rb +27 -0
- data/lib/trak_flow/mcp/tools/task_close.rb +26 -0
- data/lib/trak_flow/mcp/tools/task_create.rb +51 -0
- data/lib/trak_flow/mcp/tools/task_defer.rb +27 -0
- data/lib/trak_flow/mcp/tools/task_start.rb +25 -0
- data/lib/trak_flow/mcp/tools/task_update.rb +36 -0
- data/lib/trak_flow/mcp/tools/workflow_discard.rb +28 -0
- data/lib/trak_flow/mcp/tools/workflow_summarize.rb +34 -0
- data/lib/trak_flow/mcp.rb +38 -0
- data/lib/trak_flow/models/comment.rb +71 -0
- data/lib/trak_flow/models/dependency.rb +96 -0
- data/lib/trak_flow/models/label.rb +90 -0
- data/lib/trak_flow/models/task.rb +188 -0
- data/lib/trak_flow/storage/database.rb +638 -0
- data/lib/trak_flow/storage/jsonl.rb +259 -0
- data/lib/trak_flow/time_parser.rb +15 -0
- data/lib/trak_flow/version.rb +5 -0
- data/lib/trak_flow.rb +100 -0
- data/mkdocs.yml +143 -0
- metadata +392 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
desc "version", "Show version"
|
|
6
|
+
|
|
7
|
+
def version
|
|
8
|
+
puts "trak_flow #{VERSION}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "init", "Initialize TrakFlow in the current directory"
|
|
12
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
13
|
+
option :stealth, type: :boolean, default: false, desc: "Local-only mode without git integration"
|
|
14
|
+
|
|
15
|
+
def init
|
|
16
|
+
if TrakFlow.initialized?
|
|
17
|
+
output({ success: false, error: "TrakFlow already initialized" }) do
|
|
18
|
+
puts pastel.red("Error: TrakFlow already initialized in #{TrakFlow.trak_flow_dir}")
|
|
19
|
+
end
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
FileUtils.mkdir_p(TrakFlow.trak_flow_dir)
|
|
24
|
+
|
|
25
|
+
db = Storage::Database.new
|
|
26
|
+
db.connect
|
|
27
|
+
|
|
28
|
+
TrakFlow.config.set("stealth", options[:stealth])
|
|
29
|
+
|
|
30
|
+
jsonl = Storage::Jsonl.new
|
|
31
|
+
jsonl.write_entities([])
|
|
32
|
+
|
|
33
|
+
setup_gitignore unless options[:stealth]
|
|
34
|
+
|
|
35
|
+
output({ success: true, path: TrakFlow.trak_flow_dir }) do
|
|
36
|
+
puts pastel.green("Initialized TrakFlow in #{TrakFlow.trak_flow_dir}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "info", "Show database and configuration info"
|
|
41
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
42
|
+
|
|
43
|
+
def info
|
|
44
|
+
TrakFlow.ensure_initialized!
|
|
45
|
+
|
|
46
|
+
info_data = {
|
|
47
|
+
database_path: TrakFlow.database_path,
|
|
48
|
+
jsonl_path: TrakFlow.jsonl_path,
|
|
49
|
+
config_path: TrakFlow.config_path,
|
|
50
|
+
initialized: TrakFlow.initialized?,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
output(info_data) do
|
|
54
|
+
info_data.each { |k, v| puts "#{k}: #{v}" }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "create TITLE", "Create a new task"
|
|
59
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
60
|
+
option :type, aliases: "-t", type: :string, default: "task", desc: "Task type (bug, feature, task, epic, chore)"
|
|
61
|
+
option :priority, aliases: "-p", type: :numeric, default: 2, desc: "Priority (0=critical, 4=backlog)"
|
|
62
|
+
option :description, aliases: "-d", type: :string, desc: "Task description"
|
|
63
|
+
option :assignee, aliases: "-a", type: :string, desc: "Assignee"
|
|
64
|
+
option :labels, aliases: "-l", type: :array, desc: "Labels to add"
|
|
65
|
+
option :parent, type: :string, desc: "Parent task ID (creates child task)"
|
|
66
|
+
option :deps, type: :array, desc: "Dependencies (format: type:id)"
|
|
67
|
+
option :body_file, type: :string, desc: "Read description from file (use - for stdin)"
|
|
68
|
+
option :plan, type: :boolean, default: false, desc: "Create as a Plan (workflow blueprint)"
|
|
69
|
+
option :ephemeral, type: :boolean, default: false, desc: "Create as ephemeral (one-shot, garbage collectible)"
|
|
70
|
+
|
|
71
|
+
def create(title)
|
|
72
|
+
validate_option!(:type, TrakFlow::TYPES, options[:type])
|
|
73
|
+
validate_option!(:priority, TrakFlow::PRIORITIES, options[:priority])
|
|
74
|
+
|
|
75
|
+
with_database do |db|
|
|
76
|
+
description = options[:description]
|
|
77
|
+
|
|
78
|
+
if options[:body_file]
|
|
79
|
+
description = options[:body_file] == "-" ? $stdin.read : File.read(options[:body_file])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise ValidationError, "Plans cannot be ephemeral" if options[:plan] && options[:ephemeral]
|
|
83
|
+
|
|
84
|
+
task = if options[:parent]
|
|
85
|
+
db.create_child_task(options[:parent], {
|
|
86
|
+
title: title,
|
|
87
|
+
description: description,
|
|
88
|
+
type: options[:type],
|
|
89
|
+
priority: options[:priority],
|
|
90
|
+
assignee: options[:assignee],
|
|
91
|
+
ephemeral: options[:ephemeral],
|
|
92
|
+
})
|
|
93
|
+
else
|
|
94
|
+
new_task = Models::Task.new(
|
|
95
|
+
title: title,
|
|
96
|
+
description: description,
|
|
97
|
+
type: options[:type],
|
|
98
|
+
priority: options[:priority],
|
|
99
|
+
assignee: options[:assignee],
|
|
100
|
+
plan: options[:plan],
|
|
101
|
+
ephemeral: options[:ephemeral],
|
|
102
|
+
)
|
|
103
|
+
db.create_task(new_task)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
options[:labels]&.each do |label_name|
|
|
107
|
+
db.add_label(Models::Label.new(task_id: task.id, name: label_name))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
options[:deps]&.each do |dep_spec|
|
|
111
|
+
type, target_id = dep_spec.split(":", 2)
|
|
112
|
+
db.add_dependency(Models::Dependency.new(source_id: task.id, target_id: target_id, type: type))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
output(task.to_h) do
|
|
116
|
+
puts "Created: #{pastel.bold(task.id)} - #{task.title}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
desc "show ID", "Show task details"
|
|
122
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
123
|
+
|
|
124
|
+
def show(id)
|
|
125
|
+
with_database do |db|
|
|
126
|
+
task = db.find_task!(id)
|
|
127
|
+
labels = db.find_labels(id)
|
|
128
|
+
deps = db.find_dependencies(id)
|
|
129
|
+
comments = db.find_comments(id)
|
|
130
|
+
|
|
131
|
+
output({ task: task.to_h, labels: labels.map(&:name), dependencies: deps.map(&:to_h), comments: comments.map(&:to_h) }) do
|
|
132
|
+
print_task_details(task, labels, deps, comments)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
desc "list", "List tasks"
|
|
138
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
139
|
+
option :status, aliases: "-s", type: :string, desc: "Filter by status"
|
|
140
|
+
option :priority, aliases: "-p", type: :numeric, desc: "Filter by priority"
|
|
141
|
+
option :type, aliases: "-t", type: :string, desc: "Filter by type"
|
|
142
|
+
option :assignee, aliases: "-a", type: :string, desc: "Filter by assignee"
|
|
143
|
+
option :label, type: :array, desc: "Filter by labels (AND)"
|
|
144
|
+
option :label_any, type: :array, desc: "Filter by labels (OR)"
|
|
145
|
+
option :title_contains, type: :string, desc: "Filter by title substring"
|
|
146
|
+
option :limit, type: :numeric, desc: "Limit results"
|
|
147
|
+
|
|
148
|
+
def list
|
|
149
|
+
with_database do |db|
|
|
150
|
+
filters = {
|
|
151
|
+
status: options[:status],
|
|
152
|
+
priority: options[:priority],
|
|
153
|
+
type: options[:type],
|
|
154
|
+
assignee: options[:assignee],
|
|
155
|
+
title_contains: options[:title_contains],
|
|
156
|
+
}.compact
|
|
157
|
+
|
|
158
|
+
tasks = db.list_tasks(filters)
|
|
159
|
+
|
|
160
|
+
if options[:label]
|
|
161
|
+
tasks = tasks.select do |task|
|
|
162
|
+
task_labels = db.find_labels(task.id).map(&:name)
|
|
163
|
+
options[:label].all? { |l| task_labels.include?(l) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if options[:label_any]
|
|
168
|
+
tasks = tasks.select do |task|
|
|
169
|
+
task_labels = db.find_labels(task.id).map(&:name)
|
|
170
|
+
options[:label_any].any? { |l| task_labels.include?(l) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
tasks = tasks.take(options[:limit]) if options[:limit]
|
|
175
|
+
|
|
176
|
+
output(tasks.map(&:to_h)) do
|
|
177
|
+
if tasks.empty?
|
|
178
|
+
puts "No tasks found"
|
|
179
|
+
else
|
|
180
|
+
print_tasks_table(tasks)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
desc "update ID", "Update a task"
|
|
187
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
188
|
+
option :status, aliases: "-s", type: :string, desc: "New status"
|
|
189
|
+
option :priority, aliases: "-p", type: :numeric, desc: "New priority"
|
|
190
|
+
option :title, type: :string, desc: "New title"
|
|
191
|
+
option :description, aliases: "-d", type: :string, desc: "New description"
|
|
192
|
+
option :assignee, aliases: "-a", type: :string, desc: "New assignee"
|
|
193
|
+
|
|
194
|
+
def update(id)
|
|
195
|
+
validate_option!(:status, TrakFlow::STATUSES, options[:status])
|
|
196
|
+
validate_option!(:priority, TrakFlow::PRIORITIES, options[:priority])
|
|
197
|
+
|
|
198
|
+
with_database do |db|
|
|
199
|
+
task = db.find_task!(id)
|
|
200
|
+
|
|
201
|
+
task.status = options[:status] if options[:status]
|
|
202
|
+
task.priority = options[:priority] if options[:priority]
|
|
203
|
+
task.title = options[:title] if options[:title]
|
|
204
|
+
task.description = options[:description] if options[:description]
|
|
205
|
+
task.assignee = options[:assignee] if options[:assignee]
|
|
206
|
+
|
|
207
|
+
db.update_task(task)
|
|
208
|
+
|
|
209
|
+
output(task.to_h) do
|
|
210
|
+
puts "Updated: #{pastel.bold(task.id)} - #{task.title}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
desc "close ID", "Close a task"
|
|
216
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
217
|
+
option :reason, aliases: "-r", type: :string, desc: "Reason for closing"
|
|
218
|
+
|
|
219
|
+
def close(id)
|
|
220
|
+
with_database do |db|
|
|
221
|
+
task = db.find_task!(id)
|
|
222
|
+
task.close!(reason: options[:reason])
|
|
223
|
+
db.update_task(task)
|
|
224
|
+
|
|
225
|
+
output(task.to_h) do
|
|
226
|
+
puts "Closed: #{pastel.bold(task.id)} - #{task.title}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
desc "reopen ID", "Reopen a closed task"
|
|
232
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
233
|
+
option :reason, aliases: "-r", type: :string, desc: "Reason for reopening"
|
|
234
|
+
|
|
235
|
+
def reopen(id)
|
|
236
|
+
with_database do |db|
|
|
237
|
+
task = db.find_task!(id)
|
|
238
|
+
task.reopen!(reason: options[:reason])
|
|
239
|
+
db.update_task(task)
|
|
240
|
+
|
|
241
|
+
output(task.to_h) do
|
|
242
|
+
puts "Reopened: #{pastel.bold(task.id)} - #{task.title}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
desc "ready", "Show tasks ready for work (no blockers)"
|
|
248
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
249
|
+
|
|
250
|
+
def ready
|
|
251
|
+
with_database do |db|
|
|
252
|
+
tasks = db.ready_tasks
|
|
253
|
+
|
|
254
|
+
output(tasks.map(&:to_h)) do
|
|
255
|
+
if tasks.empty?
|
|
256
|
+
puts "No ready tasks found"
|
|
257
|
+
else
|
|
258
|
+
puts pastel.bold("Ready for work:")
|
|
259
|
+
print_tasks_table(tasks)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
desc "stale", "Show stale tasks"
|
|
266
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
267
|
+
option :days, type: :numeric, default: 30, desc: "Days since last update"
|
|
268
|
+
option :status, type: :string, desc: "Filter by status"
|
|
269
|
+
|
|
270
|
+
def stale
|
|
271
|
+
with_database do |db|
|
|
272
|
+
tasks = db.stale_tasks(days: options[:days], status: options[:status])
|
|
273
|
+
|
|
274
|
+
output(tasks.map(&:to_h)) do
|
|
275
|
+
if tasks.empty?
|
|
276
|
+
puts "No stale tasks found"
|
|
277
|
+
else
|
|
278
|
+
puts pastel.bold("Stale tasks (#{options[:days]}+ days):")
|
|
279
|
+
print_tasks_table(tasks)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
desc "sync", "Sync database with JSONL file"
|
|
286
|
+
option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
287
|
+
|
|
288
|
+
def sync
|
|
289
|
+
with_database do |db|
|
|
290
|
+
jsonl = Storage::Jsonl.new
|
|
291
|
+
|
|
292
|
+
if jsonl.exists?
|
|
293
|
+
jsonl.import(db)
|
|
294
|
+
output({ success: true, action: "imported", path: jsonl.path }) do
|
|
295
|
+
puts pastel.green("Imported from #{jsonl.path}")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
jsonl.export(db)
|
|
300
|
+
output({ success: true, action: "exported", path: jsonl.path }) do
|
|
301
|
+
puts pastel.green("Exported to #{jsonl.path}")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
unless TrakFlow.config.get("stealth") || TrakFlow.config.get("no_push")
|
|
305
|
+
git_commit_and_push
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Subcommands
|
|
311
|
+
desc "dep SUBCOMMAND", "Manage dependencies"
|
|
312
|
+
subcommand "dep", DepCommands
|
|
313
|
+
|
|
314
|
+
desc "label SUBCOMMAND", "Manage labels"
|
|
315
|
+
subcommand "label", LabelCommands
|
|
316
|
+
|
|
317
|
+
desc "plan SUBCOMMAND", "Plan operations (workflow blueprints)"
|
|
318
|
+
subcommand "plan", PlanCommands
|
|
319
|
+
|
|
320
|
+
desc "workflow SUBCOMMAND", "Workflow operations (running instances)"
|
|
321
|
+
subcommand "workflow", WorkflowCommands
|
|
322
|
+
|
|
323
|
+
desc "admin SUBCOMMAND", "Administrative commands"
|
|
324
|
+
subcommand "admin", AdminCommands
|
|
325
|
+
|
|
326
|
+
desc "config SUBCOMMAND", "Configuration management"
|
|
327
|
+
subcommand "config", ConfigCommands
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def print_task_details(task, labels, deps, comments)
|
|
332
|
+
puts pastel.bold("Task: #{task.id}")
|
|
333
|
+
puts "Title: #{task.title}"
|
|
334
|
+
puts "Status: #{colorize_status(task.status)}"
|
|
335
|
+
puts "Priority: #{colorize_priority(task.priority)}"
|
|
336
|
+
puts "Type: #{task.type}"
|
|
337
|
+
puts "Assignee: #{task.assignee || "unassigned"}"
|
|
338
|
+
puts "Parent: #{task.parent_id}" if task.parent_id
|
|
339
|
+
puts "Created: #{task.created_at}"
|
|
340
|
+
puts "Updated: #{task.updated_at}"
|
|
341
|
+
puts "Closed: #{task.closed_at}" if task.closed_at
|
|
342
|
+
puts ""
|
|
343
|
+
puts "Description:"
|
|
344
|
+
puts task.description.empty? ? "(none)" : task.description
|
|
345
|
+
puts ""
|
|
346
|
+
puts "Labels: #{labels.empty? ? "(none)" : labels.map(&:name).join(", ")}"
|
|
347
|
+
puts ""
|
|
348
|
+
puts "Dependencies:"
|
|
349
|
+
if deps.empty?
|
|
350
|
+
puts " (none)"
|
|
351
|
+
else
|
|
352
|
+
deps.each do |dep|
|
|
353
|
+
direction = dep.source_id == task.id ? "->" : "<-"
|
|
354
|
+
other_id = dep.source_id == task.id ? dep.target_id : dep.source_id
|
|
355
|
+
puts " #{direction} #{other_id} (#{dep.type})"
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
puts ""
|
|
359
|
+
puts "Comments: #{comments.size}"
|
|
360
|
+
comments.each do |comment|
|
|
361
|
+
puts " [#{comment.created_at}] #{comment.author}: #{comment.body[0, 50]}..."
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def setup_gitignore
|
|
366
|
+
gitignore_path = File.join(TrakFlow.trak_flow_dir, ".gitignore")
|
|
367
|
+
File.write(gitignore_path, "trak_flow.db\n")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def validate_option!(name, valid_values, value)
|
|
371
|
+
return if value.nil?
|
|
372
|
+
return if valid_values.include?(value)
|
|
373
|
+
|
|
374
|
+
valid_list = valid_values.join(", ")
|
|
375
|
+
raise ValidationError, "Invalid #{name}: '#{value}'. Valid options: #{valid_list}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def git_commit_and_push
|
|
379
|
+
Dir.chdir(TrakFlow.root) do
|
|
380
|
+
system("git add #{TrakFlow.jsonl_path} 2>/dev/null")
|
|
381
|
+
system("git commit -m 'trak_flow: sync tasks' #{TrakFlow.jsonl_path} 2>/dev/null")
|
|
382
|
+
system("git push 2>/dev/null") unless TrakFlow.config.get("no_push")
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Plan subcommands (workflow blueprints)
|
|
6
|
+
class PlanCommands < Thor
|
|
7
|
+
class_option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
8
|
+
|
|
9
|
+
desc "create TITLE", "Create a new Plan (workflow blueprint)"
|
|
10
|
+
option :description, aliases: "-d", type: :string, desc: "Plan description"
|
|
11
|
+
option :type, aliases: "-t", type: :string, default: "task", desc: "Task type"
|
|
12
|
+
option :priority, aliases: "-p", type: :numeric, default: 2, desc: "Priority"
|
|
13
|
+
def create(title)
|
|
14
|
+
with_database do |db|
|
|
15
|
+
plan = Models::Task.new(
|
|
16
|
+
title: title,
|
|
17
|
+
description: options[:description] || "",
|
|
18
|
+
type: options[:type],
|
|
19
|
+
priority: options[:priority],
|
|
20
|
+
plan: true
|
|
21
|
+
)
|
|
22
|
+
db.create_task(plan)
|
|
23
|
+
|
|
24
|
+
output(plan.to_h) do
|
|
25
|
+
puts "Created Plan: #{plan.id} - #{plan.title}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "list", "List Plans"
|
|
31
|
+
option :workflows, aliases: "-w", type: :boolean, default: false, desc: "Show Workflows instead of Plans"
|
|
32
|
+
def list
|
|
33
|
+
with_database do |db|
|
|
34
|
+
if options[:workflows]
|
|
35
|
+
items = db.find_workflows
|
|
36
|
+
label = "Workflows"
|
|
37
|
+
else
|
|
38
|
+
items = db.find_plans
|
|
39
|
+
label = "Plans"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
output(items.map(&:to_h)) do
|
|
43
|
+
if items.empty?
|
|
44
|
+
puts "No #{label.downcase} found"
|
|
45
|
+
else
|
|
46
|
+
puts "#{label}:"
|
|
47
|
+
items.each do |item|
|
|
48
|
+
status_info = item.plan? ? "" : " (#{item.status})"
|
|
49
|
+
ephemeral_tag = item.ephemeral? ? " [ephemeral]" : ""
|
|
50
|
+
puts " #{item.id}: #{item.title}#{status_info}#{ephemeral_tag}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc "show ID", "Show Plan with its Tasks"
|
|
58
|
+
def show(id)
|
|
59
|
+
with_database do |db|
|
|
60
|
+
plan = db.find_task!(id)
|
|
61
|
+
tasks = db.find_plan_tasks(id)
|
|
62
|
+
|
|
63
|
+
output({ plan: plan.to_h, tasks: tasks.map(&:to_h) }) do
|
|
64
|
+
puts "Plan: #{plan.id} - #{plan.title}"
|
|
65
|
+
puts "Description: #{plan.description.empty? ? "(none)" : plan.description}"
|
|
66
|
+
puts ""
|
|
67
|
+
puts "Tasks (#{tasks.size}):"
|
|
68
|
+
if tasks.empty?
|
|
69
|
+
puts " (no tasks defined)"
|
|
70
|
+
else
|
|
71
|
+
tasks.each { |task| puts " #{task.id}: #{task.title} (P#{task.priority})" }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "add PLAN_ID TITLE", "Add a Task to a Plan"
|
|
78
|
+
option :description, aliases: "-d", type: :string, desc: "Task description"
|
|
79
|
+
option :type, aliases: "-t", type: :string, default: "task", desc: "Task type"
|
|
80
|
+
option :priority, aliases: "-p", type: :numeric, default: 2, desc: "Priority"
|
|
81
|
+
def add(plan_id, title)
|
|
82
|
+
with_database do |db|
|
|
83
|
+
plan = db.find_task!(plan_id)
|
|
84
|
+
raise Error, "#{plan_id} is not a Plan" unless plan.plan?
|
|
85
|
+
|
|
86
|
+
task = db.create_child_task(plan_id, {
|
|
87
|
+
title: title,
|
|
88
|
+
description: options[:description] || "",
|
|
89
|
+
type: options[:type],
|
|
90
|
+
priority: options[:priority]
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
output(task.to_h) do
|
|
94
|
+
puts "Added Task to Plan: #{task.id} - #{task.title}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
desc "start PLAN_ID", "Create a persistent Workflow from a Plan"
|
|
100
|
+
option :var, type: :hash, default: {}, desc: "Template variables"
|
|
101
|
+
def start(plan_id)
|
|
102
|
+
instantiate_plan(plan_id, ephemeral: false)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
desc "execute PLAN_ID", "Create an ephemeral Workflow from a Plan"
|
|
106
|
+
option :var, type: :hash, default: {}, desc: "Template variables"
|
|
107
|
+
def execute(plan_id)
|
|
108
|
+
instantiate_plan(plan_id, ephemeral: true)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc "convert ID", "Convert an existing Task to a Plan"
|
|
112
|
+
def convert(id)
|
|
113
|
+
with_database do |db|
|
|
114
|
+
task = db.find_task!(id)
|
|
115
|
+
raise Error, "Task is already a Plan" if task.plan?
|
|
116
|
+
raise Error, "Cannot convert ephemeral tasks to Plans" if task.ephemeral?
|
|
117
|
+
|
|
118
|
+
db.mark_as_plan(id)
|
|
119
|
+
task = db.find_task!(id)
|
|
120
|
+
|
|
121
|
+
output(task.to_h) do
|
|
122
|
+
puts "Converted to Plan: #{task.id}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def instantiate_plan(plan_id, ephemeral:)
|
|
130
|
+
with_database do |db|
|
|
131
|
+
plan = db.find_task!(plan_id)
|
|
132
|
+
raise Error, "#{plan_id} is not a Plan" unless plan.plan?
|
|
133
|
+
|
|
134
|
+
vars = options[:var] || {}
|
|
135
|
+
|
|
136
|
+
workflow = Models::Task.new(
|
|
137
|
+
title: interpolate_vars(plan.title, vars),
|
|
138
|
+
description: interpolate_vars(plan.description, vars),
|
|
139
|
+
type: plan.type,
|
|
140
|
+
priority: plan.priority,
|
|
141
|
+
source_plan_id: plan.id,
|
|
142
|
+
ephemeral: ephemeral
|
|
143
|
+
)
|
|
144
|
+
db.create_task(workflow)
|
|
145
|
+
workflow.append_trace("INSTANTIATED", "from Plan #{plan.id}")
|
|
146
|
+
db.update_task(workflow)
|
|
147
|
+
|
|
148
|
+
db.find_plan_tasks(plan_id).each do |step|
|
|
149
|
+
db.create_child_task(workflow.id, {
|
|
150
|
+
title: interpolate_vars(step.title, vars),
|
|
151
|
+
description: interpolate_vars(step.description, vars),
|
|
152
|
+
type: step.type,
|
|
153
|
+
priority: step.priority,
|
|
154
|
+
ephemeral: ephemeral
|
|
155
|
+
})
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
mode = ephemeral ? "ephemeral" : "persistent"
|
|
159
|
+
output(workflow.to_h) do
|
|
160
|
+
puts "Created #{mode} Workflow: #{workflow.id}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def interpolate_vars(text, vars)
|
|
166
|
+
return text if text.nil? || vars.empty?
|
|
167
|
+
|
|
168
|
+
result = text.dup
|
|
169
|
+
vars.each { |key, value| result.gsub!("{{#{key}}}", value.to_s) }
|
|
170
|
+
result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Delegate helper methods to parent CLI
|
|
174
|
+
def with_database(&block) = CLI.new.with_database(&block)
|
|
175
|
+
|
|
176
|
+
def output(json_data, &human_block)
|
|
177
|
+
if options[:json]
|
|
178
|
+
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
179
|
+
else
|
|
180
|
+
human_block.call
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Workflow subcommands (running instances of Plans)
|
|
6
|
+
class WorkflowCommands < Thor
|
|
7
|
+
class_option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
8
|
+
|
|
9
|
+
desc "list", "List Workflows"
|
|
10
|
+
option :ephemeral, aliases: "-e", type: :boolean, default: false, desc: "Show only ephemeral Workflows"
|
|
11
|
+
option :plan, type: :string, desc: "Filter by source Plan ID"
|
|
12
|
+
def list
|
|
13
|
+
with_database do |db|
|
|
14
|
+
workflows = db.find_workflows(plan_id: options[:plan])
|
|
15
|
+
workflows = workflows.select(&:ephemeral?) if options[:ephemeral]
|
|
16
|
+
|
|
17
|
+
output(workflows.map(&:to_h)) do
|
|
18
|
+
if workflows.empty?
|
|
19
|
+
puts "No workflows found"
|
|
20
|
+
else
|
|
21
|
+
puts "Workflows:"
|
|
22
|
+
workflows.each do |wf|
|
|
23
|
+
ephemeral_tag = wf.ephemeral? ? " [ephemeral]" : ""
|
|
24
|
+
source_tag = wf.source_plan_id ? " (from: #{wf.source_plan_id})" : ""
|
|
25
|
+
puts " #{wf.id}: #{wf.title} (#{wf.status})#{ephemeral_tag}#{source_tag}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "show ID", "Show Workflow with its Tasks"
|
|
33
|
+
def show(id)
|
|
34
|
+
with_database do |db|
|
|
35
|
+
workflow = db.find_task!(id)
|
|
36
|
+
tasks = db.find_workflow_tasks(id)
|
|
37
|
+
|
|
38
|
+
output({ workflow: workflow.to_h, tasks: tasks.map(&:to_h) }) do
|
|
39
|
+
puts "Workflow: #{workflow.id} - #{workflow.title}"
|
|
40
|
+
puts "Status: #{workflow.status}"
|
|
41
|
+
puts "Ephemeral: #{workflow.ephemeral?}"
|
|
42
|
+
puts "Source Plan: #{workflow.source_plan_id || "(none)"}"
|
|
43
|
+
puts ""
|
|
44
|
+
puts "Tasks (#{tasks.size}):"
|
|
45
|
+
if tasks.empty?
|
|
46
|
+
puts " (no tasks)"
|
|
47
|
+
else
|
|
48
|
+
tasks.each { |task| puts " #{status_icon(task.status)} #{task.id}: #{task.title}" }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc "discard ID", "Discard an ephemeral Workflow"
|
|
55
|
+
def discard(id)
|
|
56
|
+
with_database do |db|
|
|
57
|
+
workflow = db.find_task!(id)
|
|
58
|
+
raise Error, "Can only discard ephemeral Workflows" unless workflow.discardable?
|
|
59
|
+
|
|
60
|
+
db.child_tasks(id).each { |child| db.delete_task(child.id) }
|
|
61
|
+
db.delete_task(id)
|
|
62
|
+
|
|
63
|
+
output({ discarded: id }) do
|
|
64
|
+
puts "Discarded Workflow: #{id}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "summarize ID", "Summarize and close a Workflow"
|
|
70
|
+
option :summary, aliases: "-s", type: :string, required: true, desc: "Summary text or file path"
|
|
71
|
+
def summarize(id)
|
|
72
|
+
with_database do |db|
|
|
73
|
+
workflow = db.find_task!(id)
|
|
74
|
+
|
|
75
|
+
summary_text = File.exist?(options[:summary]) ? File.read(options[:summary]) : options[:summary]
|
|
76
|
+
|
|
77
|
+
workflow.notes = "#{workflow.notes}\n\n[Summary]\n#{summary_text}".strip
|
|
78
|
+
workflow.close!(reason: "summarized")
|
|
79
|
+
db.update_task(workflow)
|
|
80
|
+
|
|
81
|
+
db.child_tasks(id).each do |child|
|
|
82
|
+
child.close!(reason: "workflow summarized")
|
|
83
|
+
db.update_task(child)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
output(workflow.to_h) do
|
|
87
|
+
puts "Summarized Workflow: #{id}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
desc "gc", "Garbage collect old ephemeral Workflows"
|
|
93
|
+
option :age, type: :string, default: "24h", desc: "Maximum age (e.g., 24h, 7d)"
|
|
94
|
+
def gc
|
|
95
|
+
with_database do |db|
|
|
96
|
+
hours = parse_duration(options[:age])
|
|
97
|
+
count = db.garbage_collect_ephemeral(max_age_hours: hours)
|
|
98
|
+
|
|
99
|
+
output({ collected: count }) do
|
|
100
|
+
puts "Collected #{count} ephemeral Workflow(s)"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def parse_duration(str)
|
|
108
|
+
match = str.match(/^(\d+)(h|d|w)$/)
|
|
109
|
+
return 24 unless match
|
|
110
|
+
|
|
111
|
+
num = match[1].to_i
|
|
112
|
+
case match[2]
|
|
113
|
+
when "h" then num
|
|
114
|
+
when "d" then num * 24
|
|
115
|
+
when "w" then num * 24 * 7
|
|
116
|
+
else 24
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Delegate helper methods to parent CLI
|
|
121
|
+
def with_database(&block) = CLI.new.with_database(&block)
|
|
122
|
+
def status_icon(status) = CLI.new.status_icon(status)
|
|
123
|
+
|
|
124
|
+
def output(json_data, &human_block)
|
|
125
|
+
if options[:json]
|
|
126
|
+
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
127
|
+
else
|
|
128
|
+
human_block.call
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|