predictability-engine 0.6.6

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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/bin/predictability-engine +19 -0
  3. data/bin/predictability-engine.bat +2 -0
  4. data/bin/setup +47 -0
  5. data/data/samples/sample_data.csv +19 -0
  6. data/data/samples/sample_data_large.csv +201 -0
  7. data/data/samples/wip_data.csv +5 -0
  8. data/lib/predictability_engine/agents/assistant.rb +29 -0
  9. data/lib/predictability_engine/agents/tools.rb +98 -0
  10. data/lib/predictability_engine/calculators/aging.rb +36 -0
  11. data/lib/predictability_engine/calculators/cfd.rb +80 -0
  12. data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
  13. data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
  14. data/lib/predictability_engine/calculators/throughput.rb +42 -0
  15. data/lib/predictability_engine/cli.rb +414 -0
  16. data/lib/predictability_engine/config.rb +167 -0
  17. data/lib/predictability_engine/data_generator.rb +53 -0
  18. data/lib/predictability_engine/data_manager.rb +28 -0
  19. data/lib/predictability_engine/data_sources/base.rb +93 -0
  20. data/lib/predictability_engine/data_sources/csv.rb +62 -0
  21. data/lib/predictability_engine/data_sources/excel.rb +18 -0
  22. data/lib/predictability_engine/data_sources/factory.rb +20 -0
  23. data/lib/predictability_engine/data_sources/jira.rb +201 -0
  24. data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
  25. data/lib/predictability_engine/duration.rb +16 -0
  26. data/lib/predictability_engine/excel_exporter.rb +48 -0
  27. data/lib/predictability_engine/html_style.rb +63 -0
  28. data/lib/predictability_engine/html_templates.rb +70 -0
  29. data/lib/predictability_engine/jira_auth/base.rb +26 -0
  30. data/lib/predictability_engine/jira_auth/basic.rb +15 -0
  31. data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
  32. data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
  33. data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
  34. data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
  35. data/lib/predictability_engine/jira_auth.rb +22 -0
  36. data/lib/predictability_engine/jira_config_prompter.rb +48 -0
  37. data/lib/predictability_engine/jira_workflow.rb +137 -0
  38. data/lib/predictability_engine/logger.rb +45 -0
  39. data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
  40. data/lib/predictability_engine/models/work_item.rb +43 -0
  41. data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
  42. data/lib/predictability_engine/pdf_visualizer.rb +64 -0
  43. data/lib/predictability_engine/raw_data_exporter.rb +46 -0
  44. data/lib/predictability_engine/report/constants.rb +70 -0
  45. data/lib/predictability_engine/report/image_generator.rb +36 -0
  46. data/lib/predictability_engine/report/text_renderer.rb +84 -0
  47. data/lib/predictability_engine/report.rb +328 -0
  48. data/lib/predictability_engine/report_generator.rb +170 -0
  49. data/lib/predictability_engine/setup_manager.rb +127 -0
  50. data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
  51. data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
  52. data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
  53. data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
  54. data/lib/predictability_engine/summary_visualizer.rb +36 -0
  55. data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
  56. data/lib/predictability_engine/terminal_visualizer.rb +97 -0
  57. data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
  58. data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
  59. data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
  60. data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
  61. data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
  62. data/lib/predictability_engine/vega_visualizer.rb +106 -0
  63. data/lib/predictability_engine/version.rb +5 -0
  64. data/lib/predictability_engine/visualizer.rb +114 -0
  65. data/lib/predictability_engine.rb +117 -0
  66. metadata +566 -0
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module PredictabilityEngine
6
+ module Calculators
7
+ module CfdForecaster
8
+ DEFAULT_HISTORY_RANGE = '2m'
9
+
10
+ def self.forecast_summary(work_items, trials: 10_000, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
11
+ backlog = work_items.reject(&:completed?).size
12
+ historical = Throughput.daily(work_items).values
13
+ return nil if historical.empty?
14
+ return nil if backlog.zero? && work_items.none? { |i| i.end_date && i.end_date > PredictabilityEngine.today }
15
+
16
+ results = simulate_backlog(backlog, historical, trials)
17
+ days_to_future = days_to_last_scheduled_event(work_items)
18
+
19
+ build_summary(work_items, results, days_to_future, percentiles)
20
+ end
21
+
22
+ def self.forecast_series(work_items, trials: 10_000, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
23
+ history_range: nil)
24
+ cfd_data = ensure_data_up_to_today(Cfd.calculate(work_items), work_items)
25
+ return nil if cfd_data.empty?
26
+
27
+ summary = forecast_summary(work_items, trials: trials, percentiles: percentiles)
28
+ return nil unless summary
29
+
30
+ history, future_data, max_days = slice_series(cfd_data, summary, percentiles, history_range)
31
+ { dates: build_dates(history, max_days), arrivals: build_arrivals(history, max_days, future_data),
32
+ departed: history.map { |d| d[:departed] }, summary: summary, max_days: max_days,
33
+ forecasts: build_forecast_map(history, summary, max_days, percentiles, future_data) }
34
+ end
35
+
36
+ def self.slice_series(cfd_data, summary, percentiles, history_range)
37
+ today_index = cfd_data.index { |d| d[:date] == PredictabilityEngine.today } || (cfd_data.size - 1)
38
+ history_days = Duration.parse(history_range || DEFAULT_HISTORY_RANGE)
39
+ history = cfd_data[0..today_index].last(history_days)
40
+ future_data = cfd_data[(today_index + 1)..] || []
41
+ max_days = [percentiles.map { |p| summary[:"p#{p}"] }.max || 0, future_data.size].max
42
+ [history, future_data, max_days]
43
+ end
44
+
45
+ def self.simulate_backlog(backlog, historical, trials)
46
+ return [0] * trials if backlog.zero?
47
+
48
+ Simulators::MonteCarlo.when_will_it_be_done(backlog, historical, trials: trials)
49
+ end
50
+
51
+ def self.days_to_last_scheduled_event(work_items)
52
+ arrival_dates = future_dates(work_items, :start_date)
53
+ departure_dates = future_dates(work_items, :end_date)
54
+ future = arrival_dates + departure_dates
55
+ future.map { |d| (d - PredictabilityEngine.today).to_i }.max || 0
56
+ end
57
+
58
+ def self.future_dates(work_items, field)
59
+ work_items.map { |i| i.send(field) }.compact.select { |d| d > PredictabilityEngine.today }
60
+ end
61
+
62
+ def self.build_summary(work_items, results, days_to_future, percentiles)
63
+ res = { wip: work_items.reject(&:completed?).size, today: PredictabilityEngine.today,
64
+ total_items: work_items.size, departed_so_far: work_items.count(&:completed?) }
65
+ percentiles.each do |p|
66
+ sim_days = Simulators::MonteCarlo.percentile(results, p)
67
+ res[:"p#{p}"] = [sim_days, days_to_future].max
68
+ end
69
+ res
70
+ end
71
+
72
+ def self.ensure_data_up_to_today(cfd_data, work_items)
73
+ if cfd_data.empty? || cfd_data.last[:date] < PredictabilityEngine.today
74
+ return Cfd.calculate(work_items, end_date: PredictabilityEngine.today)
75
+ end
76
+
77
+ cfd_data
78
+ end
79
+
80
+ def self.build_dates(history, max_days)
81
+ dates = history.map { |d| d[:date] }
82
+ (1..max_days).each { |i| dates << (history.last[:date] + i) }
83
+ dates
84
+ end
85
+
86
+ def self.build_arrivals(history, max_days, future_data)
87
+ arrivals = history.map { |d| d[:arrived] }
88
+ (1..max_days).each do |i|
89
+ arrivals << (future_data[i - 1] ? future_data[i - 1][:arrived] : history.last[:arrived])
90
+ end
91
+ arrivals
92
+ end
93
+
94
+ def self.build_forecast_map(history, summary, max_days, percentiles, future_data)
95
+ percentiles.each_with_object({}) do |p, h|
96
+ days = summary[:"p#{p}"]
97
+ res = history.map { |d| d[:departed] }
98
+ (1..max_days).each do |i|
99
+ fd = future_data[i - 1] ? future_data[i - 1][:departed] : summary[:departed_so_far]
100
+ forecasted = i <= days ? (i * (summary[:wip].to_f / days)) : summary[:wip]
101
+ res << (fd + forecasted)
102
+ end
103
+ h[p] = res
104
+ end
105
+ end
106
+
107
+ private_class_method :simulate_backlog, :days_to_last_scheduled_event, :build_summary,
108
+ :ensure_data_up_to_today, :build_dates, :build_arrivals, :build_forecast_map,
109
+ :slice_series
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module Calculators
5
+ class CycleTime
6
+ def self.distribution(work_items)
7
+ completed = work_items.select(&:completed?)
8
+ return [] if completed.empty?
9
+
10
+ completed.map(&:cycle_time).sort
11
+ end
12
+
13
+ def self.percentile(work_items, percentile_value)
14
+ dist = distribution(work_items)
15
+ return nil if dist.empty?
16
+
17
+ index = (dist.size * percentile_value / 100.0).ceil - 1
18
+ dist[index]
19
+ end
20
+
21
+ def self.completed_sorted(work_items)
22
+ PredictabilityEngine.completed_items(work_items).sort_by(&:end_date)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PredictabilityEngine
4
+ module Calculators
5
+ class Throughput
6
+ def self.daily(work_items, start_date: nil, end_date: nil)
7
+ completed = work_items.select(&:completed?)
8
+ return {} if completed.empty?
9
+
10
+ start_date ||= completed.map(&:end_date).min
11
+ end_date ||= completed.map(&:end_date).max
12
+
13
+ counts = initial_daily_counts(start_date, end_date)
14
+ populate_daily_counts(counts, completed, start_date, end_date)
15
+ end
16
+
17
+ def self.initial_daily_counts(start_date, end_date)
18
+ (start_date..end_date).to_h { |d| [d, 0] }
19
+ end
20
+
21
+ def self.populate_daily_counts(counts, completed, start_date, end_date)
22
+ completed.each do |item|
23
+ next if item.end_date < start_date || item.end_date > end_date
24
+
25
+ counts[item.end_date] += 1
26
+ end
27
+ counts
28
+ end
29
+
30
+ def self.average(work_items, start_date: nil, end_date: nil)
31
+ counts = daily(work_items, start_date: start_date, end_date: end_date).values
32
+ return 0 if counts.empty?
33
+
34
+ counts.sum.to_f / counts.size
35
+ end
36
+
37
+ def self.histogram_data(work_items)
38
+ daily(work_items).values.tally.sort
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'tty-table'
5
+ require 'fileutils'
6
+ require_relative 'visualizer'
7
+ require_relative 'summary_visualizer'
8
+
9
+ module PredictabilityEngine
10
+ # Shared Thor configuration for Cli and Viz: common class_options plus
11
+ # auto-wiring of PredictabilityEngine.setup_logging from --log-level/--log-file.
12
+ module CliBase
13
+ VALID_SIZES = Report::Constants::RESOLUTION_CONFIG.keys.freeze
14
+ SIZE_DESC = "Image size for PNG/PPT reports (#{VALID_SIZES.join(', ')})".freeze
15
+
16
+ def self.included(base)
17
+ base.extend(ClassMethods)
18
+ base.class_option :output_dir, type: :string, desc: 'Output directory for reports'
19
+ base.class_option :size, type: :string, default: Report::Constants::DEFAULT_SIZE,
20
+ enum: VALID_SIZES, desc: SIZE_DESC
21
+ base.class_option :log_level, type: :string, default: 'info',
22
+ desc: 'Logging level (debug, info, warn, error)'
23
+ base.class_option :log_file, type: :string, desc: 'Log file path'
24
+ base.class_option :url_prefix, type: :string,
25
+ desc: 'URL prefix for constructing item URLs from IDs (e.g. https://jira.example.com/browse/)'
26
+ end
27
+
28
+ module ClassMethods
29
+ def exit_on_failure?
30
+ true
31
+ end
32
+ end
33
+
34
+ def initialize(*args)
35
+ super
36
+ PredictabilityEngine.setup_logging(level: options[:log_level], log_file: options[:log_file])
37
+ end
38
+ end
39
+
40
+ class Viz < Thor
41
+ include CliBase
42
+
43
+ class_option :color, type: :boolean, default: true, desc: 'Enable/disable color output for terminal charts'
44
+
45
+ { scatter: [:cycle_time_scatter, 'Show Cycle Time scatter plot'],
46
+ throughput: [:throughput_histogram, 'Show Throughput histogram'],
47
+ cfd: [:cfd_plot, 'Show Cumulative Flow Diagram'],
48
+ aging_wip: [:aging_wip, 'Show Aging Work In Progress'],
49
+ forecasted_cfd: [:forecasted_cfd_plot,
50
+ 'Show Forecasted Cumulative Flow Diagram'] }.each do |cmd, (viz_method, description)|
51
+ desc "#{cmd} SOURCE", description
52
+ define_method(cmd) do |source|
53
+ items = PredictabilityEngine.load_items(source)
54
+ PredictabilityEngine.logger.info Visualizer.send(viz_method, items, color: options[:color])
55
+ end
56
+ end
57
+
58
+ desc 'html_scatter SOURCE [OUTPUT]', 'Generate Vega-Lite HTML scatter plot'
59
+ def html_scatter(source, output = nil)
60
+ generate_html_chart(source, output, 'scatter') do |items|
61
+ Visualizer.vega_cycle_time_scatter(items)
62
+ end
63
+ end
64
+
65
+ desc 'html_throughput SOURCE [OUTPUT]', 'Generate Vega-Lite HTML throughput histogram'
66
+ def html_throughput(source, output = nil)
67
+ generate_html_chart(source, output, 'throughput') do |items|
68
+ Visualizer.vega_throughput_histogram(items)
69
+ end
70
+ end
71
+
72
+ desc 'html_cfd SOURCE [OUTPUT]', 'Generate Vega-Lite HTML CFD'
73
+ method_option :historical_cfd_history, type: :string,
74
+ desc: 'Historical CFD window (e.g. 1w, 2m, 30d; default: full range)'
75
+ def html_cfd(source, output = nil)
76
+ generate_html_chart(source, output, 'cfd') do |items|
77
+ Visualizer.vega_cfd(items, history_range: options[:historical_cfd_history])
78
+ end
79
+ end
80
+
81
+ desc 'html_forecasted_cfd SOURCE [OUTPUT]', 'Generate Vega-Lite HTML Forecasted CFD'
82
+ method_option :forecast_history, type: :string,
83
+ desc: 'Forecasted CFD history window (e.g. 1w, 2m, 30d; default: 2m)'
84
+ def html_forecasted_cfd(source, output = nil)
85
+ generate_html_chart(source, output, 'forecasted_cfd') do |items|
86
+ Visualizer.vega_forecasted_cfd(items, history_range: options[:forecast_history])
87
+ end
88
+ end
89
+
90
+ desc 'html_aging_wip SOURCE [OUTPUT]', 'Generate Vega-Lite HTML Aging WIP'
91
+ def html_aging_wip(source, output = nil)
92
+ generate_html_chart(source, output, 'aging_wip') do |items|
93
+ Visualizer.vega_aging_wip(items)
94
+ end
95
+ end
96
+
97
+ desc 'all SOURCE', 'Show all terminal summary and visualizations'
98
+ def all(source)
99
+ run_and_print_report(source, :terminal)
100
+ end
101
+
102
+ desc 'html_all SOURCE [OUTPUT]', 'Generate a combined HTML dashboard (landscape)'
103
+ def html_all(source, output = nil)
104
+ run_and_print_report(source, :html, output: output)
105
+ end
106
+
107
+ desc 'landscape SOURCE [OUTPUT]', 'Alias for html_all'
108
+ def landscape(source, output = nil)
109
+ run_and_print_report(source, :landscape, output: output)
110
+ end
111
+
112
+ desc 'dashboard SOURCE [OUTPUT]', 'Alias for landscape'
113
+ def dashboard(source, output = nil)
114
+ landscape(source, output)
115
+ end
116
+
117
+ desc 'all_html SOURCE [OUTPUT]', 'Alias for html_all'
118
+ def all_html(source, output = nil)
119
+ html_all(source, output)
120
+ end
121
+
122
+ desc 'pdf SOURCE [OUTPUT]', 'Generate a PDF report'
123
+ def pdf(source, output = nil)
124
+ run_and_print_report(source, :pdf, output: output)
125
+ end
126
+
127
+ desc 'a3_landscape SOURCE [OUTPUT]', 'Generate an A3 landscape PDF dashboard'
128
+ def a3_landscape(source, output = nil)
129
+ run_and_print_report(source, :a3_landscape, output: output)
130
+ end
131
+
132
+ desc 'markdown SOURCE [OUTPUT]', 'Generate a Markdown report'
133
+ def markdown(source, output = nil)
134
+ run_and_print_report(source, :markdown, output: output)
135
+ end
136
+
137
+ desc 'md SOURCE [OUTPUT]', 'Alias for markdown'
138
+ def md(source, output = nil)
139
+ markdown(source, output)
140
+ end
141
+
142
+ desc 'confluence SOURCE [OUTPUT]', 'Generate a Confluence markup report'
143
+ def confluence(source, output = nil)
144
+ run_and_print_report(source, :confluence, output: output)
145
+ end
146
+
147
+ desc 'conf SOURCE [OUTPUT]', 'Alias for confluence'
148
+ def conf(source, output = nil)
149
+ confluence(source, output)
150
+ end
151
+
152
+ desc 'png SOURCE [OUTPUT]', 'Generate a PNG report'
153
+ def png(source, output = nil)
154
+ run_and_print_report(source, :png, output: output)
155
+ end
156
+
157
+ desc 'all_formats SOURCE', 'Generate all report formats at once'
158
+ def all_formats(source)
159
+ ReportGenerator.clean_report_dir(source, **options)
160
+ items = PredictabilityEngine.load_items(source, url_prefix: options[:url_prefix])
161
+ reports = Report.generate_all(items)
162
+ %i[terminal html pdf png md conf ppt raw_csv xlsx].each do |fmt|
163
+ PredictabilityEngine.run_and_print_report(source, fmt, options, items: items, reports: reports)
164
+ rescue StandardError => e
165
+ PredictabilityEngine.logger.warn { "Failed to generate #{fmt} report: #{e.message}" }
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ def run_and_print_report(source, format, output: nil)
172
+ PredictabilityEngine.run_and_print_report(source, format, options, output: output)
173
+ end
174
+
175
+ def generate_html_chart(source, output, type)
176
+ items = PredictabilityEngine.load_items(source)
177
+ path = generate_output_path(source, output, "#{type}.html")
178
+ PredictabilityEngine.write_file(path, Visualizer.to_full_html(yield(items), items))
179
+ PredictabilityEngine.logger.info { "Chart generated at #{path}" }
180
+ end
181
+
182
+ def generate_output_path(source, output, filename)
183
+ return output if output
184
+
185
+ base = File.basename(source, '.*')
186
+ dir = if options[:output_dir]
187
+ File.join(options[:output_dir], base)
188
+ else
189
+ File.join(File.dirname(source), 'reports', base)
190
+ end
191
+ File.join(dir, filename)
192
+ end
193
+ end
194
+
195
+ class Cli < Thor
196
+ include CliBase
197
+ include JiraConfigPrompter
198
+
199
+ desc 'viz SUBCOMMAND ...ARGS', 'Visualization commands'
200
+ subcommand 'viz', Viz
201
+ desc 'summary SOURCE', 'Load data from SOURCE and show flow metrics summary'
202
+ method_option :color, type: :boolean, default: true, desc: 'Enable/disable color output'
203
+ def summary(source)
204
+ items = PredictabilityEngine.load_items(source)
205
+ PredictabilityEngine.logger.info { SummaryVisualizer.metrics_terminal(items, color: options[:color]) }
206
+ end
207
+
208
+ desc 'report SOURCE FORMAT [OUTPUT]', 'Generate a full report in various formats (terminal, html, pdf, md, conf)'
209
+ method_option :color, type: :boolean, default: true, desc: 'Enable/disable color output'
210
+ method_option :clean, type: :boolean, default: true, desc: 'Clean the report directory before generation'
211
+ def report(input_source, format = 'terminal', output = nil)
212
+ if format.to_sym != :terminal && output.nil? && options[:clean]
213
+ ReportGenerator.clean_report_dir(input_source, **options)
214
+ end
215
+ PredictabilityEngine.run_and_print_report(input_source, format, options, output: output)
216
+ end
217
+
218
+ desc 'batch SOURCE', 'Run all report formats for the given SOURCE'
219
+ method_option :color, type: :boolean, default: true, desc: 'Enable/disable color output'
220
+ def batch(source)
221
+ Viz.new([], options).all_formats(source)
222
+ end
223
+
224
+ desc 'setup', 'Install/update all dependencies (Ruby gems + Node.js + Playwright + Chromium)'
225
+ def setup
226
+ SetupManager.new.run
227
+ end
228
+
229
+ desc 'init FILENAME', 'Create a template YAML file for JIRA source'
230
+ def init(filename)
231
+ filename += '.yml' unless filename.end_with?('.yml', '.yaml')
232
+ content = <<~YAML
233
+ # JIRA Data Source Configuration
234
+ # jira_profile: prod-instance # Optional: profile name from ~/.config/jira/jira_credentials.yml
235
+ # project: MYPROJ # Optional: JIRA Project Key
236
+ # filter_id: "12345" # Optional: JIRA Filter ID
237
+ # filter_name: "My Filter" # Optional: JIRA Filter Name
238
+ # query: "project = PROJ" # Optional: Direct JQL query
239
+ YAML
240
+ File.write(filename, content)
241
+ PredictabilityEngine.logger.info { "Template created at #{filename}" }
242
+ end
243
+
244
+ desc 'jira_config PROFILE', 'Generate/Update JIRA credentials in ~/.config/jira/jira_credentials.yml'
245
+ method_option :auth_mode, aliases: '-a', default: 'basic',
246
+ desc: 'Auth mode: basic | bearer | cookie | mfa_api | mfa_browser'
247
+ def jira_config(profile)
248
+ site = ask('Jira site (e.g., https://your-domain.atlassian.net):')
249
+ context_path = ask('Context path, if any (e.g., /jira — leave blank for Atlassian Cloud):')
250
+ mode = options[:auth_mode]
251
+
252
+ profile_data = build_profile_data(site, context_path, mode)
253
+
254
+ path = Config.jira_credentials_file
255
+ FileUtils.mkdir_p(File.dirname(path))
256
+
257
+ config = File.exist?(path) ? Config.load_yaml_file(path) : {}
258
+ config ||= {}
259
+ config['profiles'] ||= {}
260
+ config['profiles'][profile] = profile_data
261
+
262
+ File.write(path, config.to_yaml)
263
+ PredictabilityEngine.logger.info { "Jira credentials for profile '#{profile}' saved to #{path}" }
264
+ end
265
+
266
+ desc 'jira_workflow PROFILE [OUTPUT]',
267
+ 'Extract Jira workflow statuses for PROFILE into an editable YAML mapping ' \
268
+ '(default: ~/.config/jira/<profile>.workflow.yml). Re-running refreshes the ' \
269
+ 'snapshot while preserving any roles you already set.'
270
+ def jira_workflow(profile, output = nil)
271
+ path = output || JiraWorkflow.default_path(profile)
272
+ fresh = JiraWorkflow.extract(profile)
273
+ workflow = File.exist?(path) ? JiraWorkflow.load(path).refresh(fresh) : fresh
274
+ workflow.write(path)
275
+ action = File.exist?(path) ? 'refreshed' : 'written'
276
+ PredictabilityEngine.logger.info { "Workflow for profile '#{profile}' #{action}: #{path}" }
277
+ PredictabilityEngine.logger.info { "Review #{path} and set role: arrival / departure / null per status." }
278
+ end
279
+
280
+ desc 'jira_workflow_merge OUTPUT SOURCE1 [SOURCE2 ...]',
281
+ 'Merge multiple workflow configs into a shared config. Each SOURCE is ' \
282
+ 'either a profile name (resolved to ~/.config/jira/<profile>.workflow.yml) ' \
283
+ 'or an explicit path to a workflow YAML file.'
284
+ def jira_workflow_merge(output, *sources)
285
+ raise Error, 'Need at least one workflow source to merge' if sources.empty?
286
+
287
+ configs = sources.map do |src|
288
+ path = File.exist?(src) ? src : JiraWorkflow.default_path(src)
289
+ JiraWorkflow.load(path) || raise(Error, "Workflow not found for '#{src}' (tried #{path})")
290
+ end
291
+ JiraWorkflow.merge(configs).write(output)
292
+ PredictabilityEngine.logger.info { "Merged workflow from #{sources.join(', ')} written to #{output}" }
293
+ end
294
+
295
+ desc 'forecast SOURCE BACKLOG_COUNT', 'Run Monte Carlo simulation for BACKLOG_COUNT items'
296
+ def forecast(source, backlog_count)
297
+ items = PredictabilityEngine.load_items(source)
298
+
299
+ historical = Calculators::Throughput.daily(items).values
300
+ results = Simulators::MonteCarlo.when_will_it_be_done(backlog_count.to_i, historical)
301
+
302
+ print_forecast_results(backlog_count, results)
303
+ end
304
+
305
+ desc 'calibrate SOURCE', 'Validate Monte Carlo simulation accuracy via hindcast calibration'
306
+ method_option :validation_trials, type: :numeric, default: 200,
307
+ desc: 'Number of historical as-of dates to sample'
308
+ method_option :primary_trials, type: :numeric, default: 10_000,
309
+ desc: 'Monte Carlo trials per hindcast point'
310
+ def calibrate(source)
311
+ items = PredictabilityEngine.load_items(source)
312
+ result = Simulators::MonteCarloValidator.calibration(
313
+ items,
314
+ validation_trials: options[:validation_trials],
315
+ primary_trials: options[:primary_trials]
316
+ )
317
+ if result.nil?
318
+ PredictabilityEngine.logger.info do
319
+ 'Insufficient data for hindcast calibration (need 10+ completed items with historical WIP).'
320
+ end
321
+ return
322
+ end
323
+ print_calibration_results(result)
324
+ end
325
+
326
+ GENERATE_SIZE_DESC = "Preset volume: #{DataGenerator::PRESETS.map do |n, c|
327
+ "#{n} (#{c[:completed]}/#{c[:wip]})"
328
+ end.join(', ')}".freeze
329
+
330
+ desc 'generate OUTPUT', 'Generate a synthetic sample CSV for smoke tests and demos'
331
+ method_option :size, type: :string, default: 'medium',
332
+ enum: DataGenerator::PRESETS.keys.map(&:to_s),
333
+ desc: GENERATE_SIZE_DESC
334
+ method_option :completed, type: :numeric, desc: 'Override number of completed items'
335
+ method_option :wip, type: :numeric, desc: 'Override number of WIP items'
336
+ def generate(output)
337
+ path = DataGenerator.generate(
338
+ output: output,
339
+ size: options[:size].to_sym,
340
+ completed: options[:completed],
341
+ wip: options[:wip]
342
+ )
343
+ PredictabilityEngine.logger.info { "Synthetic #{options[:size]} dataset written to #{path}" }
344
+ end
345
+
346
+ private
347
+
348
+ def ask_secret(prompt)
349
+ if $stdin.isatty
350
+ result = ask(prompt, echo: false)
351
+ puts ''
352
+ result
353
+ else
354
+ ask(prompt)
355
+ end
356
+ end
357
+
358
+ def print_calibration_results(result)
359
+ PredictabilityEngine.logger.info { 'Monte Carlo Hindcast Calibration' }
360
+ PredictabilityEngine.logger.info { '---------------------------------' }
361
+ PredictabilityEngine.logger.info { "Trials run: #{result[:trials_run]} / Skipped: #{result[:trials_skipped]}" }
362
+ PredictabilityEngine.logger.info { '' }
363
+ PredictabilityEngine::DEFAULT_PERCENTILES.each do |p|
364
+ coverage = result[p]
365
+ next unless coverage
366
+
367
+ actual_pct = (coverage * 100).round(1)
368
+ delta = (coverage - (p / 100.0)) * 100
369
+ label = if delta > 2 then 'conservative'
370
+ elsif delta < -2 then 'optimistic'
371
+ else 'well-calibrated'
372
+ end
373
+ PredictabilityEngine.logger.info { " p#{p}: #{actual_pct}% actual coverage (#{label})" }
374
+ end
375
+ end
376
+
377
+ def print_forecast_results(backlog_count, results)
378
+ PredictabilityEngine.logger.info { 'Monte Carlo Simulation Results (When will it be done?)' }
379
+ PredictabilityEngine.logger.info { '------------------------------------------------------' }
380
+ PredictabilityEngine.logger.info { "Backlog size: #{backlog_count}" }
381
+ PredictabilityEngine.logger.info { "Number of trials: #{Simulators::MonteCarlo::DEFAULT_TRIALS}" }
382
+ PredictabilityEngine.logger.info { '' }
383
+ PredictabilityEngine.logger.info { 'Results:' }
384
+ PredictabilityEngine::DEFAULT_PERCENTILES.each do |p|
385
+ val = Simulators::MonteCarlo.percentile(results, p)
386
+ PredictabilityEngine.logger.info { " #{p}% confidence: Done in #{val} days" }
387
+ end
388
+ end
389
+
390
+ public
391
+
392
+ desc 'ask_ai SOURCE QUESTION', 'Ask the AI assistant about the data in SOURCE'
393
+ def ask_ai(source, question)
394
+ # Assistant needs the manager or at least items.
395
+ manager = DataManager.new
396
+ manager.load(source)
397
+
398
+ assistant = Agents::Assistant.new(manager)
399
+ PredictabilityEngine.logger.info { 'AI Thinking...' }
400
+ response = assistant.ask(question)
401
+
402
+ # response is an array of messages or similar depending on langchain version
403
+ # In recent langchainrb versions assistant.run returns the last message
404
+ PredictabilityEngine.logger.info { 'AI Response:' }
405
+ PredictabilityEngine.logger.info { '------------' }
406
+ # Assuming response is a message object with .content
407
+ if response.respond_to?(:content)
408
+ PredictabilityEngine.logger.info { response.content }
409
+ else
410
+ PredictabilityEngine.logger.info { response }
411
+ end
412
+ end
413
+ end
414
+ end