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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +339 -31
- data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
- data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
- data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
- data/app/controllers/plan_driven/wizard/plans_controller.rb +102 -0
- data/app/views/layouts/plan_driven/wizard/application.html.erb +139 -0
- data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
- data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
- data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
- data/app/views/plan_driven/wizard/plans/_finish.html.erb +24 -0
- data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
- data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
- data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
- data/app/views/plan_driven/wizard/plans/show.html.erb +36 -0
- data/config/routes.rb +16 -0
- data/exe/plan-driven +1 -0
- data/lib/generators/plan_driven/install_generator.rb +7 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
- data/lib/plan_driven/cli/config_commands.rb +60 -0
- data/lib/plan_driven/cli/plan_commands.rb +6 -2
- data/lib/plan_driven/cli/setup_commands.rb +10 -0
- data/lib/plan_driven/cli/ticket_commands.rb +23 -3
- data/lib/plan_driven/cli/ui.rb +41 -2
- data/lib/plan_driven/cli.rb +20 -4
- data/lib/plan_driven/configuration.rb +31 -3
- data/lib/plan_driven/connections.rb +69 -0
- data/lib/plan_driven/cursor_agents.rb +10 -2
- data/lib/plan_driven/delivery.rb +23 -5
- data/lib/plan_driven/evidence.rb +5 -1
- data/lib/plan_driven/github.rb +4 -0
- data/lib/plan_driven/guards/migration_guard.rb +86 -9
- data/lib/plan_driven/guards/ticket_guard.rb +1 -2
- data/lib/plan_driven/interview.rb +161 -0
- data/lib/plan_driven/local_agents.rb +254 -0
- data/lib/plan_driven/renderer/markdown.rb +26 -1
- data/lib/plan_driven/template.rb +7 -2
- data/lib/plan_driven/usage.rb +114 -0
- data/lib/plan_driven/version.rb +1 -1
- data/lib/plan_driven/wizard/engine.rb +19 -0
- data/lib/plan_driven/wizard.rb +245 -0
- data/lib/plan_driven.rb +5 -0
- metadata +23 -1
|
@@ -23,6 +23,7 @@ module PlanDriven
|
|
|
23
23
|
parts << "## Tickets and pull requests\n\n#{delivery_table(plan)}"
|
|
24
24
|
parts << "## Acceptance criteria and proof\n\n#{Evidence.matrix_markdown(plan)}"
|
|
25
25
|
parts << "## Checks run on each pull request\n\n#{guard_findings(plan)}"
|
|
26
|
+
parts << "## Tokens and cost\n\n#{usage_table(plan)}"
|
|
26
27
|
parts << "## Approvals\n\n#{approval_history(plan)}"
|
|
27
28
|
parts << "## Timeline\n\n#{timeline(plan)}"
|
|
28
29
|
parts << "## The approved plan\n\nThe plan this delivery implements is in `plan.md`, revision #{plan.revision}."
|
|
@@ -116,8 +117,32 @@ module PlanDriven
|
|
|
116
117
|
table(%w[When Subject Role Decision By Note], rows)
|
|
117
118
|
end
|
|
118
119
|
|
|
120
|
+
def usage_table(plan)
|
|
121
|
+
rows = Usage.rows(plan)
|
|
122
|
+
return "No token usage recorded." if rows.empty?
|
|
123
|
+
|
|
124
|
+
"#{table(%w[Step Ticket Model Input Output Cache Time Cost], rows.map { |row| usage_row(row) })}\n\n" \
|
|
125
|
+
"#{usage_total(Usage.totals(rows))}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def usage_row(row)
|
|
129
|
+
tokens = row.tokens.transform_values { |count| Usage.format_tokens(count) }
|
|
130
|
+
cache = Usage.format_tokens(row.tokens["cache_write_tokens"] + row.tokens["cache_read_tokens"])
|
|
131
|
+
time = row.duration_ms ? "#{(row.duration_ms / 60_000.0).round(1)} min" : "-"
|
|
132
|
+
[row.step.to_s, row.ticket || "-", row.model.to_s, tokens["input_tokens"], tokens["output_tokens"], cache, time,
|
|
133
|
+
Usage.format_cost(row.cost)]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def usage_total(totals)
|
|
137
|
+
tokens = Usage.format_tokens(totals[:total_tokens])
|
|
138
|
+
return "**Total: #{tokens} tokens, #{Usage.format_cost(totals[:cost])}**" if totals[:cost]
|
|
139
|
+
|
|
140
|
+
"**Total: #{tokens} tokens.** No price is set for #{totals[:unpriced].join(", ")}; " \
|
|
141
|
+
"add it to `config.token_prices` to see dollars."
|
|
142
|
+
end
|
|
143
|
+
|
|
119
144
|
def timeline(plan)
|
|
120
|
-
plan.events.map do |event|
|
|
145
|
+
plan.events.where.not(name: %w[llm.usage agent.usage]).map do |event|
|
|
121
146
|
target = event.ticket ? " #{event.ticket.key}" : ""
|
|
122
147
|
"- #{event.created_at.strftime("%-d %b %Y %H:%M")} · #{event.name}#{target} · #{event.actor}"
|
|
123
148
|
end.join("\n")
|
data/lib/plan_driven/template.rb
CHANGED
|
@@ -15,6 +15,11 @@ module PlanDriven
|
|
|
15
15
|
def drafted?
|
|
16
16
|
source == :draft
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# The question as the interview shows it, in the terminal and in the wizard.
|
|
20
|
+
def prompt
|
|
21
|
+
required ? question.to_s : "#{question} (optional)"
|
|
22
|
+
end
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
GROUPS = ["Overview", "Background", "Architectural changes", "Work overview", "Risks", "Testing"].freeze
|
|
@@ -68,7 +73,7 @@ module PlanDriven
|
|
|
68
73
|
{ key: "when", title: "When", group: "Overview", source: :ask, required: false, min_words: 0,
|
|
69
74
|
question: "When is it needed? (date, milestone, or leave empty)", guidance: "Target date or milestone." },
|
|
70
75
|
{ key: "background", title: "Background", group: "Background", source: :ask, required: false, min_words: 0,
|
|
71
|
-
question: "Links, PRDs, existing tickets, earlier decisions
|
|
76
|
+
question: "Links, PRDs, existing tickets, earlier decisions",
|
|
72
77
|
guidance: "Links, product documents, related tickets and earlier decisions." },
|
|
73
78
|
{ key: "existing_data_structure", title: "Existing Data Structure", group: "Background", source: :draft,
|
|
74
79
|
required: true, min_words: 30,
|
|
@@ -91,7 +96,7 @@ module PlanDriven
|
|
|
91
96
|
source: :draft, required: true, min_words: 5,
|
|
92
97
|
guidance: "Queues, external services, feature flags and rollout order. Say so when there are none." },
|
|
93
98
|
{ key: "out_of_scope", title: "Out of Scope", group: "Work overview", source: :ask, required: false,
|
|
94
|
-
min_words: 0, question: "What is explicitly out of scope?
|
|
99
|
+
min_words: 0, question: "What is explicitly out of scope?",
|
|
95
100
|
guidance: "What this plan deliberately doesn't do." },
|
|
96
101
|
{ key: "risks", title: "Risks", group: "Risks", source: :draft, required: true, min_words: 15,
|
|
97
102
|
guidance: "The main risks as a list, and how each is mitigated." },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# What a plan cost to deliver: tokens for every model call and agent run, recorded as events
|
|
5
|
+
# (`llm.usage`, `agent.usage`), and dollars when config.token_prices has a price for the model.
|
|
6
|
+
# Prices change and differ per account, so the gem ships none: put in what your provider charges.
|
|
7
|
+
module Usage
|
|
8
|
+
FIELDS = %w[input_tokens output_tokens cache_write_tokens cache_read_tokens].freeze
|
|
9
|
+
|
|
10
|
+
Row = Struct.new(:step, :ticket, :model, :calls, :tokens, :duration_ms, :cost, keyword_init: true) do
|
|
11
|
+
def total_tokens
|
|
12
|
+
tokens.values.sum
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Dollars for these tokens, or nil when the model has no price. Prices are per million tokens:
|
|
19
|
+
# config.token_prices = { "your-model-id" => { input: 3.0, output: 15.0, cache_write: 3.75, cache_read: 0.3 } }
|
|
20
|
+
def cost(model, tokens, config: PlanDriven.configuration)
|
|
21
|
+
price = config.token_prices.to_h.transform_keys(&:to_s)[model.to_s] or return
|
|
22
|
+
price = price.transform_keys(&:to_s)
|
|
23
|
+
FIELDS.sum { |field| tokens[field].to_i * price.fetch(field.delete_suffix("_tokens"), 0).to_f } / 1_000_000.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What the metered model spent in one step, as an `llm.usage` event.
|
|
27
|
+
def record_llm(plan, llm, step, actor:)
|
|
28
|
+
spent = llm.take
|
|
29
|
+
return if spent["calls"].zero?
|
|
30
|
+
|
|
31
|
+
plan.log!("llm.usage", actor: actor, step: step, model: llm.model, **spent.symbolize_keys)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One finished agent run. Usage is bookkeeping: a provider that can't report it mustn't stop
|
|
35
|
+
# the ticket moving.
|
|
36
|
+
def record_agent(ticket, run, agents, actor:, config: PlanDriven.configuration)
|
|
37
|
+
tokens = agents.respond_to?(:usage) ? agents.usage(ticket.agent_id, run.id) : {}
|
|
38
|
+
step = ticket.plan.events.exists?(name: "agent.usage", ticket: ticket) ? "agent follow-up" : "agent run"
|
|
39
|
+
ticket.plan.log!("agent.usage", actor: actor, ticket: ticket, step: step, run: run.id,
|
|
40
|
+
model: config.agent_model || "#{config.agent_provider} default",
|
|
41
|
+
calls: 1, duration_ms: run.duration_ms, **tokens.to_h.symbolize_keys)
|
|
42
|
+
rescue Error => e
|
|
43
|
+
ticket.plan.log!("agent.usage", actor: actor, ticket: ticket, run: run.id, error: e.message[0, 200])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tokens_from(payload)
|
|
47
|
+
FIELDS.to_h { |field| [field, payload.to_h[field].to_i] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rows(plan, config: PlanDriven.configuration)
|
|
51
|
+
plan.events.where(name: %w[llm.usage agent.usage]).order(:created_at).map do |event|
|
|
52
|
+
payload = event.payload.to_h
|
|
53
|
+
tokens = tokens_from(payload)
|
|
54
|
+
Row.new(step: payload["step"], ticket: event.ticket&.key, model: payload["model"], calls: payload["calls"].to_i,
|
|
55
|
+
tokens: tokens, duration_ms: payload["duration_ms"],
|
|
56
|
+
cost: cost(payload["model"], tokens, config: config))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def totals(rows)
|
|
61
|
+
tokens = FIELDS.to_h { |field| [field, rows.sum { |row| row.tokens[field] }] }
|
|
62
|
+
costs = rows.map(&:cost)
|
|
63
|
+
{ tokens: tokens, total_tokens: tokens.values.sum, cost: costs.any?(&:nil?) ? nil : costs.sum,
|
|
64
|
+
priced_cost: costs.compact.sum, unpriced: rows.select { |row| row.cost.nil? }.map(&:model).uniq }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_tokens(count)
|
|
68
|
+
count >= 1_000_000 ? format("%.2fM", count / 1_000_000.0) : count.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_cost(cost)
|
|
72
|
+
cost ? format("$%.2f", cost) : "-"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Wraps any model client and counts what each step spends, so Delivery can record it.
|
|
77
|
+
class MeteredLLM
|
|
78
|
+
def initialize(llm)
|
|
79
|
+
@llm = llm
|
|
80
|
+
reset
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def chat(**options)
|
|
84
|
+
reply = @llm.chat(**options)
|
|
85
|
+
@calls += 1
|
|
86
|
+
@input += reply.input_tokens.to_i
|
|
87
|
+
@output += reply.output_tokens.to_i
|
|
88
|
+
reply
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def label
|
|
92
|
+
@llm.label
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def model
|
|
96
|
+
label.split("/", 2).last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# What was spent since the last take, as an event payload.
|
|
100
|
+
def take
|
|
101
|
+
spent = { "calls" => @calls, "input_tokens" => @input, "output_tokens" => @output }
|
|
102
|
+
reset
|
|
103
|
+
spent
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def reset
|
|
109
|
+
@calls = 0
|
|
110
|
+
@input = 0
|
|
111
|
+
@output = 0
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/plan_driven/version.rb
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Wizard
|
|
5
|
+
# Mounted by the install generator, in development only:
|
|
6
|
+
#
|
|
7
|
+
# mount PlanDriven::Wizard::Engine, at: "/plan_driven" if Rails.env.development?
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
isolate_namespace PlanDriven::Wizard
|
|
10
|
+
engine_name "plan_driven_wizard"
|
|
11
|
+
config.root = File.expand_path("../../..", __dir__)
|
|
12
|
+
|
|
13
|
+
# Keys pasted on the Configuration page go to `plan-driven connect` on stdin, never to the log.
|
|
14
|
+
initializer "plan_driven_wizard.filter_parameters" do |app|
|
|
15
|
+
app.config.filter_parameters += [:api_key]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "shellwords"
|
|
8
|
+
|
|
9
|
+
module PlanDriven
|
|
10
|
+
# The browser wizard at /plan_driven. Every button runs a real `plan-driven` command in the
|
|
11
|
+
# background and streams its output to the page, so the wizard can do nothing the CLI can't,
|
|
12
|
+
# and the terminal panel shows exactly which command ran.
|
|
13
|
+
module Wizard
|
|
14
|
+
# The commands the wizard may run, built from form fields. Nothing else reaches a shell:
|
|
15
|
+
# arguments are passed as an array, never interpolated.
|
|
16
|
+
module Commands
|
|
17
|
+
PLAN_ONLY = %w[check submit pdf status evidence report usage log].freeze
|
|
18
|
+
|
|
19
|
+
BUILDERS = {
|
|
20
|
+
"doctor" => ->(_p) { [] },
|
|
21
|
+
"new" => ->(p) { [required(p, "title")] },
|
|
22
|
+
"edit" => ->(p) { [plan(p), section(p), "--from", required(p, "file")] },
|
|
23
|
+
"redraft" => ->(p) { [plan(p), section(p), required(p, "instruction")] },
|
|
24
|
+
"approve" => ->(p) { [plan(p), *role(p), *note(p)] },
|
|
25
|
+
"reject" => ->(p) { [plan(p), *role(p), "--note", required(p, "note")] },
|
|
26
|
+
"tickets" => ->(p) { [plan(p), *p["instruction"].to_s.strip.presence] },
|
|
27
|
+
"approve-tickets" => ->(p) { [plan(p), *role(p)] },
|
|
28
|
+
"develop" => ->(p) { [plan(p), *Array(p["tickets"]).map { |key| ticket_key(key) }] },
|
|
29
|
+
"review" => ->(p) { [ticket(p)] },
|
|
30
|
+
"merge" => ->(p) { [ticket(p)] },
|
|
31
|
+
"approve-pr" => ->(p) { [ticket(p), *note(p)] },
|
|
32
|
+
"feedback" => ->(p) { [ticket(p), required(p, "feedback")] },
|
|
33
|
+
"questions" => ->(_p) { [] },
|
|
34
|
+
"question" => ->(p) { [question_key(p), *question_flags(p)] },
|
|
35
|
+
# The key itself goes on stdin, so it's never in the command line or the panel.
|
|
36
|
+
"connect" => ->(p) { [Connections.find(required(p, "service")).name] }
|
|
37
|
+
}.merge(PLAN_ONLY.to_h { |name| [name, ->(p) { [plan(p)] }] }).freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def argv(action, params)
|
|
42
|
+
builder = BUILDERS[action.to_s] or raise ArgumentError, "the wizard can't run `#{action}`"
|
|
43
|
+
[action.to_s, *builder.call(params.to_h.transform_keys(&:to_s)), "--yes"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What `new` reads on stdin. The interview takes each answer up to an empty line, so blank
|
|
47
|
+
# lines inside an answer are folded away, and an empty answer is the empty line alone.
|
|
48
|
+
def interview_input(template, answers)
|
|
49
|
+
template.asked.map do |section|
|
|
50
|
+
answer = answers[section.key].to_s.strip.gsub(/\r\n?/, "\n").gsub(/\n\s*\n+/, "\n")
|
|
51
|
+
answer.empty? ? "\n" : "#{answer}\n\n"
|
|
52
|
+
end.join
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def required(params, name)
|
|
56
|
+
params[name].to_s.strip.presence or raise ArgumentError, "#{name.tr("_", " ")} is required"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def plan(params)
|
|
60
|
+
key = required(params, "plan").upcase
|
|
61
|
+
raise ArgumentError, "not a plan key" unless key.match?(/\A[A-Z]+-\d+\z/)
|
|
62
|
+
|
|
63
|
+
key
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ticket(params)
|
|
67
|
+
"#{plan(params)}/#{ticket_key(required(params, "ticket"))}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ticket_key(key)
|
|
71
|
+
key.to_s.upcase.tap { |value| raise ArgumentError, "not a ticket key" unless value.match?(/\AT\d+\z/) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def section(params)
|
|
75
|
+
key = required(params, "section")
|
|
76
|
+
PlanDriven.configuration.template[key] or raise ArgumentError, "unknown section #{key}"
|
|
77
|
+
key
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def role(params)
|
|
81
|
+
params["role"].to_s.strip.empty? ? [] : ["--as", params["role"].strip]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def note(params)
|
|
85
|
+
params["note"].to_s.strip.empty? ? [] : ["--note", params["note"].strip]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def question_key(params)
|
|
89
|
+
required(params, "key").tap do |key|
|
|
90
|
+
raise ArgumentError, "not a question key" unless key.match?(Interview::KEY)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def question_flags(params)
|
|
95
|
+
return ["--remove"] if params["remove"].to_s == "1"
|
|
96
|
+
|
|
97
|
+
flags = []
|
|
98
|
+
flags += ["--title", params["title"].strip] if params["title"].to_s.strip != ""
|
|
99
|
+
flags += ["--ask", params["question"].strip] if params["question"].to_s.strip != ""
|
|
100
|
+
flags += ["--group", params["group"]] if params["group"].to_s != ""
|
|
101
|
+
flags << "--required" if params["required"].to_s == "1"
|
|
102
|
+
flags << "--optional" if params["required"].to_s == "0"
|
|
103
|
+
flags
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One command run: its output in a log file, its state in a JSON file next to it, so any
|
|
108
|
+
# request (and any Puma thread) can read how it's going.
|
|
109
|
+
class Job
|
|
110
|
+
# About as many characters as fit on a line of the terminal panel, for tables.
|
|
111
|
+
PANEL_COLUMNS = 76
|
|
112
|
+
SERVER_ENV = %w[RAILS_ENV RACK_ENV PORT PIDFILE].freeze
|
|
113
|
+
|
|
114
|
+
attr_reader :id
|
|
115
|
+
|
|
116
|
+
def self.dir(root = PlanDriven.configuration.root_path)
|
|
117
|
+
root.join("tmp/plan_driven/wizard").tap { |path| FileUtils.mkdir_p(path) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.find(id, root: PlanDriven.configuration.root_path)
|
|
121
|
+
raise ArgumentError, "not a job" unless id.to_s.match?(/\A[a-f0-9]{16}\z/)
|
|
122
|
+
|
|
123
|
+
new(id, root: root).tap { |job| raise ArgumentError, "no job #{id}" unless job.state_file.exist? }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Starts `argv` in a thread and returns at once.
|
|
127
|
+
def self.start(argv, stdin: nil, actor: nil, root: PlanDriven.configuration.root_path)
|
|
128
|
+
job = new(SecureRandom.hex(8), root: root)
|
|
129
|
+
job.begin!(argv, stdin: stdin, actor: actor)
|
|
130
|
+
job
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def initialize(id, root:)
|
|
134
|
+
@id = id
|
|
135
|
+
@root = Pathname(root)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def begin!(argv, stdin:, actor:)
|
|
139
|
+
command = executable + argv
|
|
140
|
+
write_state("command" => display(command, actor), "status" => "running", "started_at" => Time.now.to_i)
|
|
141
|
+
File.write(log_file, "")
|
|
142
|
+
Thread.new { execute(command, stdin, actor) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def state
|
|
146
|
+
JSON.parse(state_file.read)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def output
|
|
150
|
+
log_file.exist? ? log_file.read.force_encoding(Encoding::UTF_8).scrub : ""
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def to_h
|
|
154
|
+
state.merge("id" => id, "output" => output)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def state_file
|
|
158
|
+
self.class.dir(@root).join("#{id}.json")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def log_file
|
|
162
|
+
self.class.dir(@root).join("#{id}.log")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def execute(command, stdin, actor)
|
|
168
|
+
env = { "LANG" => "en_US.UTF-8", "LC_ALL" => "en_US.UTF-8", "NO_COLOR" => "1", "COLUMNS" => PANEL_COLUMNS.to_s }
|
|
169
|
+
# The server's own environment stays with the server: a command runs as it would when typed.
|
|
170
|
+
SERVER_ENV.each { |name| env[name] = nil }
|
|
171
|
+
env["PLAN_DRIVEN_ACTOR"] = actor if actor.present?
|
|
172
|
+
status = Open3.popen2e(env, *command, chdir: @root.to_s) do |input, output, thread|
|
|
173
|
+
input.write(stdin.to_s)
|
|
174
|
+
input.close
|
|
175
|
+
File.open(log_file, "a") do |log|
|
|
176
|
+
output.each_char do |char|
|
|
177
|
+
log.write(char)
|
|
178
|
+
log.flush if char == "\n"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
thread.value
|
|
182
|
+
end
|
|
183
|
+
finish(status.exitstatus)
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
File.write(log_file, "\n✗ #{e.message}\n", mode: "a")
|
|
186
|
+
finish(1)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def finish(code)
|
|
190
|
+
write_state(state.merge("status" => code.to_i.zero? ? "succeeded" : "failed", "exit" => code,
|
|
191
|
+
"finished_at" => Time.now.to_i))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def write_state(data)
|
|
195
|
+
File.write(state_file, JSON.generate(data))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def executable
|
|
199
|
+
binstub = @root.join("bin/plan-driven")
|
|
200
|
+
binstub.exist? ? ["bin/plan-driven"] : %w[bundle exec plan-driven]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def display(command, actor)
|
|
204
|
+
prefix = actor.present? ? "PLAN_DRIVEN_ACTOR=#{Shellwords.escape(actor)} " : ""
|
|
205
|
+
shown = command.map { |arg| arg.start_with?(@root.to_s) ? arg.delete_prefix("#{@root}/") : arg }
|
|
206
|
+
prefix + Shellwords.join(shown)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The wizard's pages, in order, and which ones a plan in a given phase has reached.
|
|
211
|
+
module Steps
|
|
212
|
+
ORDER = [
|
|
213
|
+
["plan", "Plan", "Read it, edit or redraft any section, then submit"],
|
|
214
|
+
["approve", "Approve", "Every role signs off on this revision"],
|
|
215
|
+
["tickets", "Tickets", "Draft the tickets, steer them, approve them"],
|
|
216
|
+
["agents", "Agents & PRs", "One agent and one pull request per ticket"],
|
|
217
|
+
["finish", "Proof & report", "Evidence, the delivery report, tokens and cost"]
|
|
218
|
+
].freeze
|
|
219
|
+
|
|
220
|
+
REACHED = {
|
|
221
|
+
"draft" => "plan", "in_review" => "approve", "approved" => "tickets", "ticketed" => "tickets",
|
|
222
|
+
"tickets_approved" => "agents", "in_development" => "agents", "delivered" => "finish"
|
|
223
|
+
}.freeze
|
|
224
|
+
|
|
225
|
+
module_function
|
|
226
|
+
|
|
227
|
+
def keys
|
|
228
|
+
ORDER.map(&:first)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def current(plan)
|
|
232
|
+
REACHED.fetch(plan.status, "plan")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def reached?(plan, step)
|
|
236
|
+
keys.index(step).to_i <= keys.index(current(plan)).to_i
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
if defined?(Rails::Railtie)
|
|
243
|
+
require "rails/engine"
|
|
244
|
+
require_relative "wizard/engine"
|
|
245
|
+
end
|
data/lib/plan_driven.rb
CHANGED
|
@@ -12,6 +12,8 @@ require_relative "plan_driven/errors"
|
|
|
12
12
|
require_relative "plan_driven/credentials"
|
|
13
13
|
require_relative "plan_driven/configuration"
|
|
14
14
|
require_relative "plan_driven/template"
|
|
15
|
+
require_relative "plan_driven/interview"
|
|
16
|
+
require_relative "plan_driven/connections"
|
|
15
17
|
require_relative "plan_driven/schema_context"
|
|
16
18
|
require_relative "plan_driven/workflow"
|
|
17
19
|
require_relative "plan_driven/models"
|
|
@@ -26,10 +28,13 @@ require_relative "plan_driven/agent_prompt"
|
|
|
26
28
|
require_relative "plan_driven/http"
|
|
27
29
|
require_relative "plan_driven/github"
|
|
28
30
|
require_relative "plan_driven/cursor_agents"
|
|
31
|
+
require_relative "plan_driven/local_agents"
|
|
32
|
+
require_relative "plan_driven/usage"
|
|
29
33
|
require_relative "plan_driven/repository"
|
|
30
34
|
require_relative "plan_driven/renderer"
|
|
31
35
|
require_relative "plan_driven/evidence"
|
|
32
36
|
require_relative "plan_driven/delivery"
|
|
37
|
+
require_relative "plan_driven/wizard"
|
|
33
38
|
require_relative "plan_driven/railtie" if defined?(Rails::Railtie)
|
|
34
39
|
|
|
35
40
|
module PlanDriven
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plan_driven
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Blažević
|
|
@@ -66,6 +66,21 @@ files:
|
|
|
66
66
|
- CHANGELOG.md
|
|
67
67
|
- LICENSE.txt
|
|
68
68
|
- README.md
|
|
69
|
+
- app/controllers/plan_driven/wizard/application_controller.rb
|
|
70
|
+
- app/controllers/plan_driven/wizard/configuration_controller.rb
|
|
71
|
+
- app/controllers/plan_driven/wizard/jobs_controller.rb
|
|
72
|
+
- app/controllers/plan_driven/wizard/plans_controller.rb
|
|
73
|
+
- app/views/layouts/plan_driven/wizard/application.html.erb
|
|
74
|
+
- app/views/plan_driven/wizard/configuration/show.html.erb
|
|
75
|
+
- app/views/plan_driven/wizard/plans/_agents.html.erb
|
|
76
|
+
- app/views/plan_driven/wizard/plans/_approve.html.erb
|
|
77
|
+
- app/views/plan_driven/wizard/plans/_finish.html.erb
|
|
78
|
+
- app/views/plan_driven/wizard/plans/_plan.html.erb
|
|
79
|
+
- app/views/plan_driven/wizard/plans/_tickets.html.erb
|
|
80
|
+
- app/views/plan_driven/wizard/plans/index.html.erb
|
|
81
|
+
- app/views/plan_driven/wizard/plans/new.html.erb
|
|
82
|
+
- app/views/plan_driven/wizard/plans/show.html.erb
|
|
83
|
+
- config/routes.rb
|
|
69
84
|
- exe/plan-driven
|
|
70
85
|
- lib/generators/plan_driven/install_generator.rb
|
|
71
86
|
- lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt
|
|
@@ -73,11 +88,13 @@ files:
|
|
|
73
88
|
- lib/plan_driven.rb
|
|
74
89
|
- lib/plan_driven/agent_prompt.rb
|
|
75
90
|
- lib/plan_driven/cli.rb
|
|
91
|
+
- lib/plan_driven/cli/config_commands.rb
|
|
76
92
|
- lib/plan_driven/cli/plan_commands.rb
|
|
77
93
|
- lib/plan_driven/cli/setup_commands.rb
|
|
78
94
|
- lib/plan_driven/cli/ticket_commands.rb
|
|
79
95
|
- lib/plan_driven/cli/ui.rb
|
|
80
96
|
- lib/plan_driven/configuration.rb
|
|
97
|
+
- lib/plan_driven/connections.rb
|
|
81
98
|
- lib/plan_driven/credentials.rb
|
|
82
99
|
- lib/plan_driven/cursor_agents.rb
|
|
83
100
|
- lib/plan_driven/cursor_llm.mjs
|
|
@@ -95,8 +112,10 @@ files:
|
|
|
95
112
|
- lib/plan_driven/guards/ticket_guard.rb
|
|
96
113
|
- lib/plan_driven/guards/ticket_normalizer.rb
|
|
97
114
|
- lib/plan_driven/http.rb
|
|
115
|
+
- lib/plan_driven/interview.rb
|
|
98
116
|
- lib/plan_driven/json_reply.rb
|
|
99
117
|
- lib/plan_driven/llm.rb
|
|
118
|
+
- lib/plan_driven/local_agents.rb
|
|
100
119
|
- lib/plan_driven/models.rb
|
|
101
120
|
- lib/plan_driven/models/approval.rb
|
|
102
121
|
- lib/plan_driven/models/event.rb
|
|
@@ -114,7 +133,10 @@ files:
|
|
|
114
133
|
- lib/plan_driven/schema_context.rb
|
|
115
134
|
- lib/plan_driven/template.rb
|
|
116
135
|
- lib/plan_driven/ticket_generator.rb
|
|
136
|
+
- lib/plan_driven/usage.rb
|
|
117
137
|
- lib/plan_driven/version.rb
|
|
138
|
+
- lib/plan_driven/wizard.rb
|
|
139
|
+
- lib/plan_driven/wizard/engine.rb
|
|
118
140
|
- lib/plan_driven/workflow.rb
|
|
119
141
|
homepage: https://github.com/blaz1988/plan-driven
|
|
120
142
|
licenses:
|