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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +3 -0
  3. data/CHANGELOG.md +69 -0
  4. data/COMMITS.md +196 -0
  5. data/Gemfile +8 -0
  6. data/Gemfile.lock +281 -0
  7. data/README.md +479 -0
  8. data/Rakefile +16 -0
  9. data/bin/tf +6 -0
  10. data/bin/tf_mcp +81 -0
  11. data/docs/.keep +0 -0
  12. data/docs/api/database.md +434 -0
  13. data/docs/api/ruby-library.md +349 -0
  14. data/docs/api/task-model.md +341 -0
  15. data/docs/assets/stylesheets/extra.css +53 -0
  16. data/docs/assets/trak_flow.jpg +0 -0
  17. data/docs/cli/admin-commands.md +369 -0
  18. data/docs/cli/dependency-commands.md +321 -0
  19. data/docs/cli/label-commands.md +222 -0
  20. data/docs/cli/overview.md +163 -0
  21. data/docs/cli/plan-commands.md +344 -0
  22. data/docs/cli/task-commands.md +333 -0
  23. data/docs/core-concepts/dependencies.md +232 -0
  24. data/docs/core-concepts/labels.md +217 -0
  25. data/docs/core-concepts/overview.md +178 -0
  26. data/docs/core-concepts/plans-workflows.md +264 -0
  27. data/docs/core-concepts/tasks.md +205 -0
  28. data/docs/getting-started/configuration.md +120 -0
  29. data/docs/getting-started/installation.md +79 -0
  30. data/docs/getting-started/quick-start.md +245 -0
  31. data/docs/index.md +169 -0
  32. data/docs/mcp/integration.md +302 -0
  33. data/docs/mcp/overview.md +206 -0
  34. data/docs/mcp/resources.md +284 -0
  35. data/docs/mcp/tools.md +457 -0
  36. data/examples/basic_usage.rb +365 -0
  37. data/examples/cli_demo.sh +314 -0
  38. data/examples/mcp/Gemfile +9 -0
  39. data/examples/mcp/Gemfile.lock +226 -0
  40. data/examples/mcp/http_demo.rb +232 -0
  41. data/examples/mcp/stdio_demo.rb +146 -0
  42. data/lib/trak_flow/cli/admin_commands.rb +136 -0
  43. data/lib/trak_flow/cli/config_commands.rb +260 -0
  44. data/lib/trak_flow/cli/dep_commands.rb +71 -0
  45. data/lib/trak_flow/cli/label_commands.rb +76 -0
  46. data/lib/trak_flow/cli/main_commands.rb +386 -0
  47. data/lib/trak_flow/cli/plan_commands.rb +185 -0
  48. data/lib/trak_flow/cli/workflow_commands.rb +133 -0
  49. data/lib/trak_flow/cli.rb +110 -0
  50. data/lib/trak_flow/config/defaults.yml +114 -0
  51. data/lib/trak_flow/config/section.rb +74 -0
  52. data/lib/trak_flow/config.rb +276 -0
  53. data/lib/trak_flow/graph/dependency_graph.rb +288 -0
  54. data/lib/trak_flow/id_generator.rb +52 -0
  55. data/lib/trak_flow/mcp/resources/base_resource.rb +25 -0
  56. data/lib/trak_flow/mcp/resources/dependency_graph.rb +31 -0
  57. data/lib/trak_flow/mcp/resources/label_list.rb +21 -0
  58. data/lib/trak_flow/mcp/resources/plan_by_id.rb +27 -0
  59. data/lib/trak_flow/mcp/resources/plan_list.rb +21 -0
  60. data/lib/trak_flow/mcp/resources/task_by_id.rb +31 -0
  61. data/lib/trak_flow/mcp/resources/task_list.rb +21 -0
  62. data/lib/trak_flow/mcp/resources/task_next.rb +30 -0
  63. data/lib/trak_flow/mcp/resources/workflow_by_id.rb +27 -0
  64. data/lib/trak_flow/mcp/resources/workflow_list.rb +21 -0
  65. data/lib/trak_flow/mcp/server.rb +140 -0
  66. data/lib/trak_flow/mcp/tools/base_tool.rb +29 -0
  67. data/lib/trak_flow/mcp/tools/comment_add.rb +33 -0
  68. data/lib/trak_flow/mcp/tools/dep_add.rb +34 -0
  69. data/lib/trak_flow/mcp/tools/dep_remove.rb +25 -0
  70. data/lib/trak_flow/mcp/tools/label_add.rb +28 -0
  71. data/lib/trak_flow/mcp/tools/label_remove.rb +25 -0
  72. data/lib/trak_flow/mcp/tools/plan_add_step.rb +35 -0
  73. data/lib/trak_flow/mcp/tools/plan_create.rb +33 -0
  74. data/lib/trak_flow/mcp/tools/plan_run.rb +58 -0
  75. data/lib/trak_flow/mcp/tools/plan_start.rb +58 -0
  76. data/lib/trak_flow/mcp/tools/task_block.rb +27 -0
  77. data/lib/trak_flow/mcp/tools/task_close.rb +26 -0
  78. data/lib/trak_flow/mcp/tools/task_create.rb +51 -0
  79. data/lib/trak_flow/mcp/tools/task_defer.rb +27 -0
  80. data/lib/trak_flow/mcp/tools/task_start.rb +25 -0
  81. data/lib/trak_flow/mcp/tools/task_update.rb +36 -0
  82. data/lib/trak_flow/mcp/tools/workflow_discard.rb +28 -0
  83. data/lib/trak_flow/mcp/tools/workflow_summarize.rb +34 -0
  84. data/lib/trak_flow/mcp.rb +38 -0
  85. data/lib/trak_flow/models/comment.rb +71 -0
  86. data/lib/trak_flow/models/dependency.rb +96 -0
  87. data/lib/trak_flow/models/label.rb +90 -0
  88. data/lib/trak_flow/models/task.rb +188 -0
  89. data/lib/trak_flow/storage/database.rb +638 -0
  90. data/lib/trak_flow/storage/jsonl.rb +259 -0
  91. data/lib/trak_flow/time_parser.rb +15 -0
  92. data/lib/trak_flow/version.rb +5 -0
  93. data/lib/trak_flow.rb +100 -0
  94. data/mkdocs.yml +143 -0
  95. 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