plan_driven 0.1.0 → 0.2.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +339 -31
  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 +102 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +139 -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 +24 -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 +36 -0
  18. data/config/routes.rb +16 -0
  19. data/exe/plan-driven +1 -0
  20. data/lib/generators/plan_driven/install_generator.rb +7 -0
  21. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  22. data/lib/plan_driven/cli/config_commands.rb +60 -0
  23. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  24. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  25. data/lib/plan_driven/cli/ticket_commands.rb +23 -3
  26. data/lib/plan_driven/cli/ui.rb +41 -2
  27. data/lib/plan_driven/cli.rb +20 -4
  28. data/lib/plan_driven/configuration.rb +31 -3
  29. data/lib/plan_driven/connections.rb +69 -0
  30. data/lib/plan_driven/cursor_agents.rb +10 -2
  31. data/lib/plan_driven/delivery.rb +23 -5
  32. data/lib/plan_driven/evidence.rb +5 -1
  33. data/lib/plan_driven/github.rb +4 -0
  34. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  35. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  36. data/lib/plan_driven/interview.rb +161 -0
  37. data/lib/plan_driven/local_agents.rb +254 -0
  38. data/lib/plan_driven/renderer/markdown.rb +26 -1
  39. data/lib/plan_driven/template.rb +7 -2
  40. data/lib/plan_driven/usage.rb +114 -0
  41. data/lib/plan_driven/version.rb +1 -1
  42. data/lib/plan_driven/wizard/engine.rb +19 -0
  43. data/lib/plan_driven/wizard.rb +245 -0
  44. data/lib/plan_driven.rb +5 -0
  45. metadata +23 -1
@@ -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,102 @@
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 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 run
37
+ job = start(Commands.argv(params[:do], command_fields))
38
+ redirect_to plan_path(@plan.key, params[:step].presence, job: job.id, open: params[:open].presence,
39
+ anchor: params[:open].presence)
40
+ rescue ArgumentError => e
41
+ redirect_to plan_path(@plan.key, params[:step].presence), alert: e.message
42
+ end
43
+
44
+ def run_global
45
+ job = start(Commands.argv(params[:do], {}))
46
+ redirect_to root_path(job: job.id)
47
+ rescue ArgumentError => e
48
+ redirect_to root_path, alert: e.message
49
+ end
50
+
51
+ def actor
52
+ session[:plan_driven_actor] = params[:actor].to_s.strip.presence
53
+ redirect_back fallback_location: root_path
54
+ end
55
+
56
+ def file
57
+ path = Renderer.directory(@plan).join(params[:name])
58
+ return head(:not_found) unless path.file?
59
+
60
+ send_file path, disposition: "inline"
61
+ end
62
+
63
+ private
64
+
65
+ def find_plan
66
+ @plan = Plan.find_by_reference!(params[:key])
67
+ rescue ActiveRecord::RecordNotFound => e
68
+ redirect_to root_path, alert: e.message
69
+ end
70
+
71
+ def command_fields
72
+ fields = params.permit(:section, :instruction, :role, :note, :ticket, :feedback, :text, tickets: []).to_h
73
+ fields[:plan] = @plan.key
74
+ text = fields.delete("text")
75
+ fields[:file] = section_file(text) if params[:do] == "edit"
76
+ fields
77
+ end
78
+
79
+ def interview_answers
80
+ params.fetch(:answers, {}).permit(*@template.asked.map(&:key)).to_h
81
+ end
82
+
83
+ def missing_answers(missing)
84
+ flash.now[:alert] = "#{missing.map(&:title).join(", ")} #{missing.one? ? "is" : "are"} required."
85
+ render(:new, status: :unprocessable_entity)
86
+ end
87
+
88
+ def interview_input(answers)
89
+ Commands.interview_input(@template, answers)
90
+ end
91
+
92
+ # `edit --from` reads the new text from a file; it stays in tmp/ so the command can be rerun.
93
+ def section_file(text)
94
+ dir = Job.dir.join("sections")
95
+ FileUtils.mkdir_p(dir)
96
+ path = dir.join("#{@plan.key}-#{params[:section]}-#{SecureRandom.hex(4)}.md")
97
+ File.write(path, text.to_s.gsub("\r\n", "\n"))
98
+ path.relative_path_from(PlanDriven.configuration.root_path).to_s
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,139 @@
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
+ .flash { padding:10px 14px; border-radius:8px; margin-bottom:12px; background:#fee2e2; color:var(--bad); }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <header class="top">
72
+ <a class="brand" href="<%= root_path %>">plan<span>_</span>driven <span class="muted" style="font-weight:400">wizard</span></a>
73
+ <nav><%= link_to "Plans", root_path %> <%= link_to "Configuration", configuration_path %></nav>
74
+ <%= form_with url: actor_path, method: :post do |f| %>
75
+ acting as <%= f.text_field :actor, value: acting_as %> <button type="submit">Set</button>
76
+ <% end %>
77
+ </header>
78
+ <div class="layout">
79
+ <main>
80
+ <% if flash[:alert] %><div class="flash"><%= flash[:alert] %></div><% end %>
81
+ <%= yield %>
82
+ </main>
83
+ <aside class="terminal" data-job="<%= current_job && job_path(current_job.id) %>"
84
+ data-plan-url="<%= plan_path("PLAN_KEY") %>" data-running="<%= current_job&.state&.dig("status") == "running" %>">
85
+ <div class="bar">
86
+ <span>●</span><span>terminal · what the wizard runs</span>
87
+ <span class="state <%= current_job&.state&.dig("status") %>"><%= current_job ? current_job.state["status"] : "idle" %></span>
88
+ </div>
89
+ <% if current_job %>
90
+ <pre id="term"><span class="cmd">$ <%= current_job.state["command"] %></span>
91
+ <%= current_job.output %></pre>
92
+ <% else %>
93
+ <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>
94
+ <% end %>
95
+ </aside>
96
+ </div>
97
+ <script>
98
+ (function () {
99
+ var aside = document.querySelector("aside.terminal");
100
+ var url = aside.dataset.job;
101
+ if (!url) return;
102
+ var pre = document.getElementById("term");
103
+ var badge = aside.querySelector(".state");
104
+ var wasRunning = aside.dataset.running === "true";
105
+ function escape(text) { return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
106
+ function paint(text) {
107
+ return escape(text).split("\n").map(function (line) {
108
+ if (/^\s*✓/.test(line)) return '<span class="ok">' + line + "</span>";
109
+ if (/^\s*!/.test(line)) return '<span class="warn">' + line + "</span>";
110
+ if (/^\s*✗/.test(line)) return '<span class="bad">' + line + "</span>";
111
+ return line;
112
+ }).join("\n");
113
+ }
114
+ function render(job) {
115
+ pre.innerHTML = '<span class="cmd">$ ' + escape(job.command) + "</span>\n" + paint(job.output);
116
+ pre.scrollTop = pre.scrollHeight;
117
+ badge.textContent = job.status;
118
+ badge.className = "state " + job.status;
119
+ }
120
+ function poll() {
121
+ fetch(url, { headers: { Accept: "application/json" } }).then(function (r) { return r.json(); }).then(function (job) {
122
+ render(job);
123
+ if (job.status === "running") { document.body.classList.add("busy"); return setTimeout(poll, 600); }
124
+ document.body.classList.remove("busy");
125
+ if (!wasRunning) return;
126
+ setTimeout(function () {
127
+ if (job.plan && !/\/plans\/PD-/.test(location.pathname)) {
128
+ location.href = aside.dataset.planUrl.replace("PLAN_KEY", job.plan) + location.search;
129
+ } else {
130
+ location.reload();
131
+ }
132
+ }, 900);
133
+ });
134
+ }
135
+ poll();
136
+ })();
137
+ </script>
138
+ </body>
139
+ </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,24 @@
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
+ <% files = %w[delivery-report.html delivery-report.pdf].select { |name| PlanDriven::Renderer.directory(@plan).join(name).file? } %>
19
+ <% if files.any? %>
20
+ <div class="card">
21
+ <strong>Delivery report</strong>
22
+ <% files.each do |name| %> · <%= link_to name, plan_file_path(@plan.key, name), target: "_blank", rel: "noopener" %><% end %>
23
+ </div>
24
+ <% 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 %>
@@ -0,0 +1,42 @@
1
+ <% can_draft = %w[approved ticketed].include?(@plan.status) %>
2
+ <div class="card">
3
+ <%= form_with url: run_plan_path(@plan.key), method: :post, class: "row" do %>
4
+ <%= hidden_field_tag :do, "tickets" %><%= hidden_field_tag :step, "tickets" %>
5
+ <%= text_field_tag :instruction, nil, placeholder: @plan.tickets.any? ? "what to change, e.g. split T2 into model and UI" : "optional guidance for the tickets",
6
+ disabled: !can_draft %>
7
+ <button type="submit" class="<%= "primary" if @plan.tickets.empty? %>" <%= "disabled" unless can_draft %>>
8
+ <%= @plan.tickets.any? ? "Redraft tickets" : "Draft tickets" %></button>
9
+ <% end %>
10
+ </div>
11
+
12
+ <% if @plan.tickets.any? %>
13
+ <div class="card">
14
+ <table>
15
+ <thead><tr><th>Ticket</th><th>Type</th><th>Est.</th><th>Depends on</th><th>Status</th></tr></thead>
16
+ <tbody>
17
+ <% @plan.tickets.each do |ticket| %>
18
+ <tr>
19
+ <td><strong><%= ticket.key %></strong> <%= ticket.title %>
20
+ <details><summary class="muted">acceptance criteria</summary>
21
+ <ul><% ticket.criteria.each do |criterion| %><li><%= criterion.is_a?(Hash) ? criterion.values.join(" ") : criterion %></li><% end %></ul>
22
+ </details></td>
23
+ <td><%= ticket.ticket_type %></td>
24
+ <td><%= ticket.estimate %></td>
25
+ <td><%= ticket.dependencies.join(", ").presence || "—" %></td>
26
+ <td><span class="pill"><%= ticket.status.tr("_", " ") %></span></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ </div>
32
+
33
+ <% if @plan.status == "ticketed" %>
34
+ <div class="card row">
35
+ <span>Approvals needed: <strong><%= @plan.missing_ticket_approvals.join(", ").presence || "none" %></strong></span>
36
+ <span style="margin-left:auto"></span>
37
+ <% @plan.missing_ticket_approvals.each do |role| %>
38
+ <%= run_button "Approve tickets as #{role}", "approve-tickets", { role: role }, primary: true %>
39
+ <% end %>
40
+ </div>
41
+ <% end %>
42
+ <% end %>