plan_driven 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/README.md +437 -37
  4. data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
  5. data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
  6. data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
  7. data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
  9. data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
  10. data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
  11. data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
  12. data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
  13. data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
  14. data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
  15. data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
  16. data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
  17. data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
  18. data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
  19. data/config/routes.rb +17 -0
  20. data/exe/plan-driven +1 -0
  21. data/lib/generators/plan_driven/install_generator.rb +7 -0
  22. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  23. data/lib/plan_driven/charts.rb +252 -0
  24. data/lib/plan_driven/cli/config_commands.rb +60 -0
  25. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  26. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  27. data/lib/plan_driven/cli/ticket_commands.rb +64 -3
  28. data/lib/plan_driven/cli/ui.rb +41 -2
  29. data/lib/plan_driven/cli.rb +21 -4
  30. data/lib/plan_driven/configuration.rb +31 -3
  31. data/lib/plan_driven/connections.rb +69 -0
  32. data/lib/plan_driven/cursor_agents.rb +10 -2
  33. data/lib/plan_driven/delivery.rb +23 -5
  34. data/lib/plan_driven/evidence.rb +11 -4
  35. data/lib/plan_driven/github.rb +4 -0
  36. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  37. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  38. data/lib/plan_driven/interview.rb +161 -0
  39. data/lib/plan_driven/local_agents.rb +254 -0
  40. data/lib/plan_driven/renderer/html.rb +34 -5
  41. data/lib/plan_driven/renderer/markdown.rb +67 -3
  42. data/lib/plan_driven/renderer/style.css +21 -0
  43. data/lib/plan_driven/renderer.rb +11 -4
  44. data/lib/plan_driven/statistics.rb +195 -0
  45. data/lib/plan_driven/template.rb +7 -2
  46. data/lib/plan_driven/usage.rb +114 -0
  47. data/lib/plan_driven/version.rb +1 -1
  48. data/lib/plan_driven/wizard/engine.rb +19 -0
  49. data/lib/plan_driven/wizard.rb +245 -0
  50. data/lib/plan_driven.rb +8 -1
  51. metadata +32 -6
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ module Wizard
5
+ # The interview's questions and the service connections. Like every other page, it only
6
+ # reads; each form runs one `plan-driven` command.
7
+ class ConfigurationController < ApplicationController
8
+ def show
9
+ config = PlanDriven.configuration
10
+ @template = config.template
11
+ @base = config.base_template
12
+ @needed = Connections.needed(config).map(&:name)
13
+ end
14
+
15
+ def question
16
+ job = start(Commands.argv("question", question_fields))
17
+ redirect_to configuration_path(job: job.id, anchor: "questions")
18
+ rescue ArgumentError => e
19
+ redirect_to configuration_path(anchor: "questions"), alert: e.message
20
+ end
21
+
22
+ def connect
23
+ service = Connections.find(params[:service])
24
+ key = params[:api_key].to_s.strip
25
+ raise ArgumentError, "Paste the #{service.title} key first." if key.empty?
26
+
27
+ job = start(Commands.argv("connect", service: service.name), stdin: "#{key}\n")
28
+ redirect_to configuration_path(job: job.id, anchor: "connections")
29
+ rescue ArgumentError => e
30
+ redirect_to configuration_path(anchor: "connections"), alert: e.message
31
+ end
32
+
33
+ def check
34
+ job = start(Commands.argv(params[:do] == "questions" ? "questions" : "doctor", {}))
35
+ redirect_to configuration_path(job: job.id, anchor: params[:do] == "questions" ? "questions" : "connections")
36
+ end
37
+
38
+ private
39
+
40
+ # Only what differs from the question as it is now, so an unchanged field stays the default.
41
+ def question_fields
42
+ key = params[:key].to_s.strip
43
+ return { "key" => key, "remove" => "1" } if params[:remove].present?
44
+
45
+ current = PlanDriven.configuration.template[key]
46
+ fields = changed_text(current).merge("key" => key)
47
+ required = params[:required] == "1"
48
+ fields["required"] = required ? "1" : "0" if current.nil? || required != current.required
49
+ fields
50
+ end
51
+
52
+ def changed_text(current)
53
+ %w[title question group].each_with_object({}) do |name, fields|
54
+ value = params[name].to_s.strip
55
+ next if value.empty? || (current && value == current.public_send(name).to_s)
56
+
57
+ fields[name] = value
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ module Wizard
5
+ # Polled by the terminal panel while a command runs.
6
+ class JobsController < ApplicationController
7
+ def show
8
+ data = Job.find(params[:id]).to_h
9
+ data["plan"] = data["output"][/✓ (PD-\d+) drafted/, 1]
10
+ render json: data
11
+ rescue ArgumentError => e
12
+ render json: { error: e.message }, status: :not_found
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module PlanDriven
6
+ module Wizard
7
+ # Every form posts here, and every post becomes one `plan-driven` command. The pages only read.
8
+ class PlansController < ApplicationController
9
+ before_action :find_plan, only: %i[show statistics run file]
10
+
11
+ def index
12
+ @plans = Plan.order(id: :desc)
13
+ end
14
+
15
+ def new
16
+ @template = PlanDriven.configuration.template
17
+ end
18
+
19
+ def create
20
+ @template = PlanDriven.configuration.template
21
+ answers = interview_answers
22
+ missing = @template.asked.select { |section| section.required && answers[section.key].to_s.strip.empty? }
23
+ return missing_answers(missing) if missing.any?
24
+
25
+ job = start(Commands.argv("new", title: params[:title]), stdin: interview_input(answers))
26
+ redirect_to root_path(job: job.id)
27
+ rescue ArgumentError => e
28
+ redirect_to new_plan_path, alert: e.message
29
+ end
30
+
31
+ def show
32
+ @step = params[:step].presence || Steps.current(@plan)
33
+ @template = PlanDriven.configuration.template
34
+ end
35
+
36
+ def statistics
37
+ @step = "statistics"
38
+ @stats = Statistics.new(@plan)
39
+ end
40
+
41
+ def run
42
+ job = start(Commands.argv(params[:do], command_fields))
43
+ redirect_to after_run(job)
44
+ rescue ArgumentError => e
45
+ redirect_to plan_path(@plan.key, params[:step].presence.presence_in(Steps.keys)), alert: e.message
46
+ end
47
+
48
+ def run_global
49
+ job = start(Commands.argv(params[:do], {}))
50
+ redirect_to root_path(job: job.id)
51
+ rescue ArgumentError => e
52
+ redirect_to root_path, alert: e.message
53
+ end
54
+
55
+ def actor
56
+ session[:plan_driven_actor] = params[:actor].to_s.strip.presence
57
+ redirect_back fallback_location: root_path
58
+ end
59
+
60
+ def file
61
+ path = Renderer.directory(@plan).join(params[:name])
62
+ return head(:not_found) unless path.file?
63
+
64
+ send_file path, disposition: "inline"
65
+ end
66
+
67
+ private
68
+
69
+ def after_run(job)
70
+ return plan_statistics_path(@plan.key, job: job.id) if params[:step] == "statistics"
71
+
72
+ plan_path(@plan.key, params[:step].presence, job: job.id, open: params[:open].presence,
73
+ anchor: params[:open].presence)
74
+ end
75
+
76
+ def find_plan
77
+ @plan = Plan.find_by_reference!(params[:key])
78
+ rescue ActiveRecord::RecordNotFound => e
79
+ redirect_to root_path, alert: e.message
80
+ end
81
+
82
+ def command_fields
83
+ fields = params.permit(:section, :instruction, :role, :note, :ticket, :feedback, :text, tickets: []).to_h
84
+ fields[:plan] = @plan.key
85
+ text = fields.delete("text")
86
+ fields[:file] = section_file(text) if params[:do] == "edit"
87
+ fields
88
+ end
89
+
90
+ def interview_answers
91
+ params.fetch(:answers, {}).permit(*@template.asked.map(&:key)).to_h
92
+ end
93
+
94
+ def missing_answers(missing)
95
+ flash.now[:alert] = "#{missing.map(&:title).join(", ")} #{missing.one? ? "is" : "are"} required."
96
+ render(:new, status: :unprocessable_entity)
97
+ end
98
+
99
+ def interview_input(answers)
100
+ Commands.interview_input(@template, answers)
101
+ end
102
+
103
+ # `edit --from` reads the new text from a file; it stays in tmp/ so the command can be rerun.
104
+ def section_file(text)
105
+ dir = Job.dir.join("sections")
106
+ FileUtils.mkdir_p(dir)
107
+ path = dir.join("#{@plan.key}-#{params[:section]}-#{SecureRandom.hex(4)}.md")
108
+ File.write(path, text.to_s.gsub("\r\n", "\n"))
109
+ path.relative_path_from(PlanDriven.configuration.root_path).to_s
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,151 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for?(:title) ? yield(:title) : "plan_driven" %></title>
7
+ <%= csrf_meta_tags %>
8
+ <style>
9
+ :root { --ink:#1f2330; --muted:#6b7280; --line:#e5e7eb; --bg:#f7f7f9; --accent:#cc342d; --ok:#15803d; --warn:#b45309; --bad:#b91c1c; }
10
+ * { box-sizing: border-box; }
11
+ [id] { scroll-margin-top: 64px; }
12
+ body { margin:0; font:15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif; color:var(--ink); background:var(--bg); }
13
+ a { color:var(--accent); }
14
+ header.top { display:flex; align-items:center; gap:16px; padding:12px 24px; height:53px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10; }
15
+ header.top .brand { font-weight:700; text-decoration:none; color:var(--ink); }
16
+ header.top .brand span { color:var(--accent); }
17
+ header.top form { margin-left:auto; display:flex; gap:6px; align-items:center; color:var(--muted); font-size:13px; }
18
+ .layout { display:grid; grid-template-columns:minmax(0, 1fr) minmax(360px, 42%); min-height:calc(100vh - 53px); }
19
+ main { padding:24px 32px 48px; }
20
+ aside.terminal { background:#0f1117; color:#d6d9e0; display:flex; flex-direction:column; position:sticky; top:53px; height:calc(100vh - 53px); }
21
+ .terminal .bar { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid #232734; font-size:12px; color:#8b93a7; }
22
+ .terminal .bar .state { margin-left:auto; padding:2px 8px; border-radius:10px; background:#232734; }
23
+ .terminal .bar .state.running { background:#1e3a8a; color:#dbeafe; }
24
+ .terminal .bar .state.succeeded { background:#14532d; color:#dcfce7; }
25
+ .terminal .bar .state.failed { background:#7f1d1d; color:#fee2e2; }
26
+ .terminal pre { margin:0; padding:14px; overflow:auto; flex:1; font:13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space:pre-wrap; word-break:break-word; }
27
+ .terminal .cmd { color:#fbbf24; }
28
+ .terminal .ok { color:#4ade80; } .terminal .warn { color:#fbbf24; } .terminal .bad { color:#f87171; }
29
+ .terminal .empty { color:#6b7280; padding:14px; font-size:13px; }
30
+ h1 { font-size:24px; margin:0 0 4px; } h2 { font-size:18px; margin:28px 0 8px; } h3 { font-size:15px; margin:18px 0 6px; }
31
+ .muted { color:var(--muted); }
32
+ .card { background:#fff; border:1px solid var(--line); border-radius:10px; padding:16px 18px; margin:12px 0; }
33
+ .steps { display:flex; gap:4px; margin:16px 0 20px; padding:0; list-style:none; }
34
+ .steps li { flex:1; }
35
+ .steps a, .steps span { display:block; padding:8px 10px; border-radius:8px; font-size:13px; text-decoration:none; color:var(--muted); background:#eef0f3; }
36
+ .steps a { color:var(--ink); background:#fff; border:1px solid var(--line); }
37
+ .steps .here { background:var(--accent); color:#fff; border-color:var(--accent); }
38
+ .steps b { display:block; font-size:14px; }
39
+ .nav { display:flex; justify-content:space-between; margin-top:28px; }
40
+ button, .button { font:inherit; font-size:14px; padding:7px 14px; border-radius:8px; border:1px solid var(--line); background:#fff; color:var(--ink); cursor:pointer; text-decoration:none; display:inline-block; }
41
+ button.primary, .button.primary { background:var(--accent); color:#fff; border-color:var(--accent); }
42
+ button:disabled, .button.disabled { opacity:.45; cursor:not-allowed; pointer-events:none; }
43
+ body.busy form button { opacity:.45; pointer-events:none; }
44
+ input[type=text], textarea { font:inherit; width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
45
+ textarea { min-height:90px; font:13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
46
+ header.top input[type=text] { width:260px; padding:4px 8px; font-size:13px; }
47
+ label { display:block; font-weight:600; margin:14px 0 4px; }
48
+ label small { font-weight:400; color:var(--muted); }
49
+ .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
50
+ .row input[type=text] { flex:1; min-width:180px; }
51
+ table { border-collapse:collapse; width:100%; font-size:14px; }
52
+ th, td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
53
+ .pill { display:inline-block; font-size:12px; padding:1px 8px; border-radius:10px; background:#eef0f3; }
54
+ .pill.good { background:#dcfce7; color:var(--ok); } .pill.bad { background:#fee2e2; color:var(--bad); } .pill.wait { background:#fef3c7; color:var(--warn); }
55
+ .errors li { color:var(--bad); } .warnings li { color:var(--warn); }
56
+ details.section { background:#fff; border:1px solid var(--line); border-radius:10px; margin:8px 0; }
57
+ details.section > summary { padding:10px 14px; cursor:pointer; font-weight:600; }
58
+ details.section > div { padding:0 16px 14px; }
59
+ .doc table { font-size:13px; } .doc pre { background:#f3f4f6; padding:10px; border-radius:6px; overflow:auto; font-size:12px; }
60
+ header.top nav { display:flex; gap:14px; margin-left:12px; font-size:14px; }
61
+ header.top nav a { color:var(--muted); text-decoration:none; } header.top nav a:hover { color:var(--ink); }
62
+ input[type=password], select { font:inherit; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
63
+ .row input[type=password] { flex:1; min-width:180px; }
64
+ .grid2 { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 2fr); gap:12px; }
65
+ label.check { display:inline-flex; gap:6px; align-items:center; font-weight:400; margin:0; }
66
+ .card.unused { opacity:.75; }
67
+ .kpis { display:grid; grid-template-columns:repeat(auto-fill, minmax(170px, 1fr)); gap:10px; margin:16px 0 4px; }
68
+ .kpi { background:#fff; border:1px solid var(--line); border-top:3px solid #3b82f6; border-radius:10px; padding:12px 14px; display:flex; flex-direction:column; gap:2px; }
69
+ .kpi small { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
70
+ .kpi strong { font-size:26px; line-height:1.15; } .kpi strong span { font-size:16px; color:var(--muted); font-weight:500; }
71
+ .kpi .muted { font-size:12px; }
72
+ .kpi.good { border-top-color:var(--ok); } .kpi.good strong { color:var(--ok); }
73
+ .kpi.bad { border-top-color:var(--bad); } .kpi.bad strong { color:var(--bad); }
74
+ .card.chart { padding:6px; } .card.chart svg { display:block; width:100%; height:auto; }
75
+ .swatch { display:inline-block; width:8px; height:8px; border-radius:2px; margin-right:6px; vertical-align:middle; }
76
+ .phases { display:flex; gap:0; border-radius:8px; overflow:hidden; border:1px solid var(--line); }
77
+ .phases div { flex:1; padding:10px 12px; border-right:1px solid var(--line); display:flex; flex-direction:column; }
78
+ .phases div:last-child { border-right:0; } .phases small { color:var(--muted); } .phases .pending { background:#f9fafb; color:var(--muted); }
79
+ .flash { padding:10px 14px; border-radius:8px; margin-bottom:12px; background:#fee2e2; color:var(--bad); }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <header class="top">
84
+ <a class="brand" href="<%= root_path %>">plan<span>_</span>driven <span class="muted" style="font-weight:400">wizard</span></a>
85
+ <nav><%= link_to "Plans", root_path %> <%= link_to "Configuration", configuration_path %></nav>
86
+ <%= form_with url: actor_path, method: :post do |f| %>
87
+ acting as <%= f.text_field :actor, value: acting_as %> <button type="submit">Set</button>
88
+ <% end %>
89
+ </header>
90
+ <div class="layout">
91
+ <main>
92
+ <% if flash[:alert] %><div class="flash"><%= flash[:alert] %></div><% end %>
93
+ <%= yield %>
94
+ </main>
95
+ <aside class="terminal" data-job="<%= current_job && job_path(current_job.id) %>"
96
+ data-plan-url="<%= plan_path("PLAN_KEY") %>" data-running="<%= current_job&.state&.dig("status") == "running" %>">
97
+ <div class="bar">
98
+ <span>●</span><span>terminal · what the wizard runs</span>
99
+ <span class="state <%= current_job&.state&.dig("status") %>"><%= current_job ? current_job.state["status"] : "idle" %></span>
100
+ </div>
101
+ <% if current_job %>
102
+ <pre id="term"><span class="cmd">$ <%= current_job.state["command"] %></span>
103
+ <%= current_job.output %></pre>
104
+ <% else %>
105
+ <div class="empty">Every button on the left runs one plan-driven command. It shows up here, with its output, exactly as it would in your terminal.</div>
106
+ <% end %>
107
+ </aside>
108
+ </div>
109
+ <script>
110
+ (function () {
111
+ var aside = document.querySelector("aside.terminal");
112
+ var url = aside.dataset.job;
113
+ if (!url) return;
114
+ var pre = document.getElementById("term");
115
+ var badge = aside.querySelector(".state");
116
+ var wasRunning = aside.dataset.running === "true";
117
+ function escape(text) { return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
118
+ function paint(text) {
119
+ return escape(text).split("\n").map(function (line) {
120
+ if (/^\s*✓/.test(line)) return '<span class="ok">' + line + "</span>";
121
+ if (/^\s*!/.test(line)) return '<span class="warn">' + line + "</span>";
122
+ if (/^\s*✗/.test(line)) return '<span class="bad">' + line + "</span>";
123
+ return line;
124
+ }).join("\n");
125
+ }
126
+ function render(job) {
127
+ pre.innerHTML = '<span class="cmd">$ ' + escape(job.command) + "</span>\n" + paint(job.output);
128
+ pre.scrollTop = pre.scrollHeight;
129
+ badge.textContent = job.status;
130
+ badge.className = "state " + job.status;
131
+ }
132
+ function poll() {
133
+ fetch(url, { headers: { Accept: "application/json" } }).then(function (r) { return r.json(); }).then(function (job) {
134
+ render(job);
135
+ if (job.status === "running") { document.body.classList.add("busy"); return setTimeout(poll, 600); }
136
+ document.body.classList.remove("busy");
137
+ if (!wasRunning) return;
138
+ setTimeout(function () {
139
+ if (job.plan && !/\/plans\/PD-/.test(location.pathname)) {
140
+ location.href = aside.dataset.planUrl.replace("PLAN_KEY", job.plan) + location.search;
141
+ } else {
142
+ location.reload();
143
+ }
144
+ }, 900);
145
+ });
146
+ }
147
+ poll();
148
+ })();
149
+ </script>
150
+ </body>
151
+ </html>
@@ -0,0 +1,104 @@
1
+ <% content_for :title, "Configuration" %>
2
+ <% config = PlanDriven.configuration %>
3
+ <h1>Configuration</h1>
4
+ <p class="muted">Connect the services this app uses, and change the interview. Every button runs one
5
+ <code>plan-driven</code> command; the panel on the right shows it.</p>
6
+
7
+ <h2 id="connections">Connections</h2>
8
+ <div class="card">
9
+ <p style="margin-top:0">
10
+ Plans and tickets are drafted with <strong><%= config.llm_provider %> · <%= config.llm_model %></strong>.
11
+ Tickets go to
12
+ <strong><%= config.agent_provider.to_sym == :local ? "local agents (#{config.agent_command.presence || "no agent_command set"})" : "Cursor cloud agents" %></strong>.
13
+ </p>
14
+ <p class="muted" style="margin-bottom:0">To use another model or agent, change it in
15
+ <code>config/initializers/plan_driven.rb</code>, for example <code>config.llm_provider = :anthropic</code>, or
16
+ <code>config.agent_provider = :local</code> with <code>config.agent_command = "claude -p"</code>, then restart the server.</p>
17
+ </div>
18
+
19
+ <% PlanDriven::Connections::SERVICES.sort_by { |s| @needed.include?(s.name) ? 0 : 1 }.each do |service| %>
20
+ <% source = PlanDriven::Credentials.source(service.key) %>
21
+ <% used = @needed.include?(service.name) %>
22
+ <div class="card service <%= "unused" unless used %>" id="service_<%= service.name %>">
23
+ <div class="row" style="justify-content:space-between">
24
+ <div>
25
+ <strong><%= service.title %></strong>
26
+ <% if source %>
27
+ <span class="pill good">key set · <%= source.sub(Dir.home, "~") %></span>
28
+ <% elsif used %>
29
+ <span class="pill bad">not connected</span>
30
+ <% else %>
31
+ <span class="pill">not used by this configuration</span>
32
+ <% end %>
33
+ <div class="muted" style="font-size:13px"><%= service.purpose %>.
34
+ <a href="<%= service.url %>" target="_blank" rel="noopener">Get a key ↗</a></div>
35
+ </div>
36
+ </div>
37
+ <%= form_with url: configuration_connect_path, method: :post, class: "row connect", style: "margin-top:10px" do %>
38
+ <%= hidden_field_tag :service, service.name %>
39
+ <%= password_field_tag :api_key, nil, id: "api_key_#{service.name}", autocomplete: "off",
40
+ placeholder: "Paste the #{service.title} key", "aria-label": "#{service.title} key" %>
41
+ <button type="submit" class="<%= "primary" if used && !source %>"><%= source ? "Replace" : "Connect" %></button>
42
+ <% end %>
43
+ </div>
44
+ <% end %>
45
+ <p class="muted" style="font-size:13px">A key is checked with the service first, then stored in
46
+ <code>~/.plan_driven/config</code> (0600), never in the app. It goes to the command on stdin, so it isn't in
47
+ the panel or the logs. A key in the environment always wins.</p>
48
+ <%= button_to "Check every connection", configuration_check_path(do: "doctor"), form: { style: "display:inline" } %>
49
+
50
+ <h2 id="questions">Interview questions</h2>
51
+ <p class="muted">What <code>plan-driven new</code> and the New plan form ask. Changes are written to
52
+ <code><%= PlanDriven::Interview::PATH %></code>; commit it so the whole team gets the same interview. A new required question
53
+ also applies to plans still in draft: they need an answer before they can be submitted.</p>
54
+
55
+ <% @template.asked.each do |section| %>
56
+ <% origin = PlanDriven::Interview.origin(section.key, template: @base) %>
57
+ <div class="card question" id="question_<%= section.key %>">
58
+ <%= form_with url: configuration_question_path, method: :post do %>
59
+ <%= hidden_field_tag :key, section.key %>
60
+ <div class="row" style="justify-content:space-between">
61
+ <span><code><%= section.key %></code>
62
+ <span class="pill <%= origin == "default" ? "" : "wait" %>"><%= origin %></span>
63
+ <span class="muted" style="font-size:13px"><%= section.group %></span></span>
64
+ <label class="check"><%= hidden_field_tag :required, "0", id: nil %>
65
+ <%= check_box_tag :required, "1", section.required, id: "required_#{section.key}" %> required</label>
66
+ </div>
67
+ <div class="grid2">
68
+ <div><label for="title_<%= section.key %>">Title</label>
69
+ <%= text_field_tag :title, section.title, id: "title_#{section.key}" %></div>
70
+ <div><label for="question_text_<%= section.key %>">Question</label>
71
+ <%= text_field_tag :question, section.question, id: "question_text_#{section.key}" %></div>
72
+ </div>
73
+ <div class="row" style="margin-top:10px">
74
+ <button type="submit">Save</button>
75
+ <% unless origin == "default" %>
76
+ <button type="submit" name="remove" value="1"><%= origin == "added" ? "Remove" : "Back to the default" %></button>
77
+ <% end %>
78
+ </div>
79
+ <% end %>
80
+ </div>
81
+ <% end %>
82
+
83
+ <div class="card" id="new_question">
84
+ <h3 style="margin-top:0">Add a question</h3>
85
+ <%= form_with url: configuration_question_path, method: :post do %>
86
+ <div class="grid2">
87
+ <div><label for="new_key">Key <small>lower case and _, like success_metric</small></label>
88
+ <%= text_field_tag :key, nil, id: "new_key", pattern: "[a-z][a-z0-9_]{1,39}", required: true %></div>
89
+ <div><label for="new_title">Title</label>
90
+ <%= text_field_tag :title, nil, id: "new_title", required: true %></div>
91
+ </div>
92
+ <label for="new_question_text">Question</label>
93
+ <%= text_field_tag :question, nil, id: "new_question_text", required: true,
94
+ placeholder: "How will we know it worked?" %>
95
+ <div class="row" style="margin-top:10px">
96
+ <label for="new_group" style="margin:0">Plan section group</label>
97
+ <%= select_tag :group, options_for_select(PlanDriven::Template::GROUPS, "Overview"), id: "new_group" %>
98
+ <label class="check"><%= hidden_field_tag :required, "0", id: nil %>
99
+ <%= check_box_tag :required, "1", false, id: "new_required" %> required</label>
100
+ <button type="submit" class="primary" style="margin-left:auto">Add question</button>
101
+ </div>
102
+ <% end %>
103
+ </div>
104
+ <%= button_to "List the questions", configuration_check_path(do: "questions"), form: { style: "display:inline" } %>
@@ -0,0 +1,44 @@
1
+ <div class="card row">
2
+ <span class="muted">Each ready ticket goes to its own coding agent, which opens one pull request.
3
+ Tickets wait for the ones they depend on.</span>
4
+ <span style="margin-left:auto"></span>
5
+ <%= run_button "Refresh agents & PRs", "status" %>
6
+ <%= run_button "Start ready tickets", "develop", {}, primary: true,
7
+ disabled: !%w[tickets_approved in_development].include?(@plan.status) %>
8
+ </div>
9
+
10
+ <div class="card">
11
+ <table>
12
+ <thead><tr><th>Ticket</th><th>Status</th><th>Agent / PR</th><th></th></tr></thead>
13
+ <tbody>
14
+ <% @plan.tickets.each do |ticket| %>
15
+ <tr>
16
+ <td><strong><%= ticket.key %></strong> <%= ticket.title %>
17
+ <% if ticket.status == "approved" && ticket.blocked_by.any? %><div class="muted">waits for <%= ticket.blocked_by.join(", ") %></div><% end %></td>
18
+ <td><span class="pill <%= { "merged" => "good", "failed" => "bad", "changes_requested" => "bad" }.fetch(ticket.status, "wait") %>"><%= ticket.status.tr("_", " ") %></span></td>
19
+ <td>
20
+ <% if ticket.agent_url.present? %><%= link_to "agent", ticket.agent_url, target: "_blank", rel: "noopener" %><% end %>
21
+ <% if ticket.pr_url.present? %><%= link_to "PR ##{ticket.pr_number}", ticket.pr_url, target: "_blank", rel: "noopener" %><% end %>
22
+ </td>
23
+ <td>
24
+ <% if %w[pr_open pr_approved changes_requested].include?(ticket.status) %>
25
+ <div class="row">
26
+ <%= run_button "Review", "review", { ticket: ticket.key } %>
27
+ <%= run_button "Approve PR", "approve-pr", { ticket: ticket.key }, disabled: ticket.status != "pr_open" %>
28
+ <%= run_button "Merge", "merge", { ticket: ticket.key }, primary: true, disabled: ticket.status != "pr_approved" %>
29
+ </div>
30
+ <%= form_with url: run_plan_path(@plan.key), method: :post, class: "row", html: { style: "margin-top:6px" } do %>
31
+ <%= hidden_field_tag :do, "feedback", id: nil %><%= hidden_field_tag :step, "agents", id: nil %>
32
+ <%= hidden_field_tag :ticket, ticket.key, id: nil %>
33
+ <%= text_field_tag :feedback, nil, id: "feedback_#{ticket.key}", placeholder: "ask the agent for a change" %>
34
+ <button type="submit">Send</button>
35
+ <% end %>
36
+ <% elsif ticket.status == "failed" || ticket.ready? %>
37
+ <%= run_button "Start #{ticket.key}", "develop", { tickets: [ticket.key] } %>
38
+ <% end %>
39
+ </td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
44
+ </div>
@@ -0,0 +1,40 @@
1
+ <% roles = PlanDriven.configuration.plan_approvals.map(&:to_s) %>
2
+ <p class="muted">Approvals belong to a revision: editing the plan after this sends it back for approval.
3
+ You're signing as <strong><%= acting_as %></strong>.</p>
4
+
5
+ <div class="card">
6
+ <table>
7
+ <thead><tr><th>Role</th><th>Revision <%= @plan.revision %></th><th></th></tr></thead>
8
+ <tbody>
9
+ <% roles.each do |role| %>
10
+ <% approval = @plan.approvals_for_revision.where(role: role).order(:created_at).last %>
11
+ <tr>
12
+ <td><strong><%= role %></strong></td>
13
+ <td>
14
+ <% if approval&.decision == "approved" %>
15
+ <span class="pill good">approved</span> <span class="muted">by <%= approval.actor %></span>
16
+ <% elsif approval %>
17
+ <span class="pill bad"><%= approval.decision %></span> <span class="muted"><%= approval.note %></span>
18
+ <% else %>
19
+ <span class="pill wait">waiting</span>
20
+ <% end %>
21
+ </td>
22
+ <td>
23
+ <% if @plan.status == "in_review" && approval&.decision != "approved" %>
24
+ <%= form_with url: run_plan_path(@plan.key), method: :post, class: "row" do %>
25
+ <%= hidden_field_tag :step, "approve", id: nil %><%= hidden_field_tag :role, role, id: nil %>
26
+ <%= text_field_tag :note, nil, id: "note_#{role}", placeholder: "note (needed to send it back)" %>
27
+ <button type="submit" name="do" value="approve" class="primary">Approve</button>
28
+ <button type="submit" name="do" value="reject">Send back</button>
29
+ <% end %>
30
+ <% end %>
31
+ </td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+
38
+ <% if @plan.status == "draft" %>
39
+ <p class="muted">The plan is a draft; submit it on the Plan step first.</p>
40
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <div class="card row">
2
+ <span class="muted">Run the plan's Cucumber scenarios on the merged code, then write the report that ties
3
+ every acceptance criterion to its proof.</span>
4
+ <span style="margin-left:auto"></span>
5
+ <%= run_button "Run the evidence", "evidence" %>
6
+ <%= run_button "Tokens & cost", "usage" %>
7
+ <%= run_button "Write the delivery report", "report", {}, primary: true %>
8
+ </div>
9
+
10
+ <% if (run = @plan.evidence_runs.last) %>
11
+ <div class="card">
12
+ <strong>Last evidence run</strong>
13
+ <span class="pill <%= run.passed? ? "good" : "bad" %>"><%= run.status %></span>
14
+ <span class="muted"><%= run.scenarios.size %> scenarios<%= " at #{run.commit_sha.to_s[0, 7]}" if run.commit_sha %></span>
15
+ </div>
16
+ <% end %>
17
+
18
+ <div class="card row">
19
+ <strong>Statistics</strong>
20
+ <span class="muted">Where the time went, criteria merged and proven, agents and people.</span>
21
+ <span style="margin-left:auto"></span>
22
+ <%= link_to "Open the statistics →", plan_statistics_path(@plan.key), class: "button" %>
23
+ </div>
24
+
25
+ <% files = %w[delivery-report.html delivery-report.pdf].select { |name| PlanDriven::Renderer.directory(@plan).join(name).file? } %>
26
+ <% if files.any? %>
27
+ <div class="card">
28
+ <strong>Delivery report</strong>
29
+ <% files.each do |name| %> · <%= link_to name, plan_file_path(@plan.key, name), target: "_blank", rel: "noopener" %><% end %>
30
+ </div>
31
+ <% end %>
@@ -0,0 +1,52 @@
1
+ <% report = PlanDriven::Guards::Report.from_h(@plan.guard_report) %>
2
+ <div class="card">
3
+ <div class="row">
4
+ <strong>Guards</strong>
5
+ <span class="pill <%= report.ok? ? "good" : "bad" %>"><%= report.ok? ? "all checks passed" : "#{report.errors.size} to fix" %></span>
6
+ <span style="margin-left:auto"></span>
7
+ <%= run_button "Run the guards", "check" %>
8
+ <%= run_button "Write HTML & PDF", "pdf" %>
9
+ <%= run_button "Submit for review", "submit", primary: true, disabled: !(@plan.status == "draft" && report.ok?) %>
10
+ </div>
11
+ <%= guard_list(report) %>
12
+ <p class="muted" style="margin:6px 0 0">
13
+ <% %w[plan.html plan.pdf].each do |name| %>
14
+ <% if PlanDriven::Renderer.directory(@plan).join(name).file? %>
15
+ <%= link_to name, plan_file_path(@plan.key, name), target: "_blank", rel: "noopener" %>
16
+ <% end %>
17
+ <% end %>
18
+ </p>
19
+ </div>
20
+
21
+ <p class="muted">Every section can be changed: edit the text yourself, or tell the model what to change.
22
+ Either one is a new revision, checked by the guards again<%= ", and sends an approved plan back to draft" if @plan.status != "draft" %>.</p>
23
+
24
+ <% @template.grouped.each do |group, sections| %>
25
+ <% next if sections.empty? %>
26
+ <h2><%= group %></h2>
27
+ <% sections.each do |section| %>
28
+ <details class="section" id="<%= section.key %>" <%= "open" if params[:open] == section.key %>>
29
+ <summary><%= section.title %>
30
+ <% if @plan.section(section.key).strip.empty? %><span class="pill wait">empty</span><% end %></summary>
31
+ <div>
32
+ <div class="doc"><%= markdown(@plan.section(section.key)) %></div>
33
+ <% if @plan.editable? %>
34
+ <% fields = { step: "plan", section: section.key, open: section.key } %>
35
+ <h3>Edit the text</h3>
36
+ <%= form_with url: run_plan_path(@plan.key), method: :post do %>
37
+ <% fields.merge(do: "edit").each do |name, value| %><%= hidden_field_tag name, value, id: nil %><% end %>
38
+ <%= text_area_tag :text, @plan.section(section.key), rows: 10, id: "text_#{section.key}" %>
39
+ <p><button type="submit">Save <%= section.title %></button></p>
40
+ <% end %>
41
+ <h3>Or ask for a redraft</h3>
42
+ <%= form_with url: run_plan_path(@plan.key), method: :post, class: "row" do %>
43
+ <% fields.merge(do: "redraft").each do |name, value| %><%= hidden_field_tag name, value, id: nil %><% end %>
44
+ <%= text_field_tag :instruction, nil, id: "instruction_#{section.key}",
45
+ placeholder: "e.g. add a colour column to categories" %>
46
+ <button type="submit">Redraft</button>
47
+ <% end %>
48
+ <% end %>
49
+ </div>
50
+ </details>
51
+ <% end %>
52
+ <% end %>