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
@@ -4,16 +4,29 @@ module PlanDriven
4
4
  module Guards
5
5
  # Zero-downtime rules for the Database changes section: expand first, contract last.
6
6
  class MigrationGuard
7
- DESTRUCTIVE = /\b(remove|drop|delete|rename)\w*\b[^.\n]{0,80}\b(column|table|field)s?\b|
8
- \b(remove_column|drop_table|rename_column|rename_table|change_column)\b/ix
9
- SAFE_REMOVAL = /ignored_columns|expand|contract|in a later (step|phase|release)|after (the )?backfill/i
7
+ # Removing a constraint, an index or a foreign key loses no data, so `remove_check_constraint`
8
+ # is not a removal; the migration methods that drop columns are listed by name.
9
+ DESTRUCTIVE = /
10
+ \b(remov(e|es|ed|ing|al)|drop(s|ped|ping)?|delet(e|es|ed|ing|ion)|renam(e|es|ed|ing))\b
11
+ [^.\n]{0,80}\b(column|table|field)s?\b
12
+ | \b(remove_columns?|remove_reference|remove_belongs_to|remove_timestamps|drop_table|
13
+ rename_column|rename_table|change_column)\b
14
+ /ix
15
+ # A removal is staged when its own sentence says so, or when it sits under a contract step.
16
+ SAFE_REMOVAL = Regexp.union(/ignored_columns|\bcontract\b/i, /in a later (step|phase|release|migration|deploy)/i,
17
+ /after (the )?(backfill|deploy)|once (reads|the code|no code)/i)
18
+ LATER_STEP = /\b(contract|clean[\s-]?up|later (step|phase|release|migration|deploy)|follow[\s-]?up)\b/i
19
+ ROLLBACK = /\b(roll[\s-]?back|down migration|undo)\b/i
20
+ NEGATED = /\b(no|not|never|none|nothing|don't|doesn't|won't|without|isn't|aren't)\b/i
21
+ UNCHANGED = /\b(not\s(be\s)?(changed|modified|touched)|unchanged|untouched|no\schanges?\b|
22
+ keeps?\s(its|their)\s(current|existing)|stays?\sthe\ssame|as\s(it|they)\s(is|are)\stoday)/ix
10
23
  NOT_NULL = /\bnot[\s_-]?null\b|null:\s*false/i
11
24
  NOT_NULL_SAFE = /default|backfill|nullable first|after (the )?backfill|validate/i
12
25
  INDEX = /\badd_index\b|\bindex(es)?\b/i
13
26
  CONCURRENT = /concurrent|algorithm:\s*:concurrently|disable_ddl_transaction/i
14
27
  NEW_TABLE = /
15
28
  create_table\s+[:"']?([a-z][a-z0-9_]+)
16
- | new\s+table:?\s*`?([a-z][a-z0-9_]+)
29
+ | new\s+table(?::\s*`?|\s+`)([a-z][a-z0-9_]+)
17
30
  | create\s+(?:a\s+|the\s+)?(?:new\s+)?`([a-z][a-z0-9_]+)`\s+table
18
31
  | create\s+(?:a\s+|the\s+)?(?:new\s+)?table\s+`?([a-z][a-z0-9_]+)
19
32
  | create:?\s+`([a-z][a-z0-9_]+)`(?!\s+(?:column|index))
@@ -35,18 +48,82 @@ module PlanDriven
35
48
  report
36
49
  end
37
50
 
51
+ # Tables the section creates. One that's already in the schema isn't new, however it's
52
+ # mentioned (a plan citing `20260929_create_rsvps.rb` as an example creates nothing).
38
53
  def new_tables
39
- @text.scan(NEW_TABLE).flatten.compact.map(&:downcase).uniq
54
+ @text.scan(NEW_TABLE).flatten.compact.map(&:downcase).uniq - @schema.tables
40
55
  end
41
56
 
42
57
  private
43
58
 
44
59
  def check_destructive(report)
45
- return unless @text.match?(DESTRUCTIVE)
46
- return if @text.match?(SAFE_REMOVAL)
60
+ created = new_tables
61
+ unsafe = destructive_statements.reject do |statement, heading|
62
+ statement.match?(SAFE_REMOVAL) || statement.match?(ROLLBACK) ||
63
+ heading.to_s.match?(LATER_STEP) || heading.to_s.match?(ROLLBACK) ||
64
+ only_new_tables?(statement, created)
65
+ end
66
+ return if unsafe.empty?
67
+
68
+ report.error("Database changes remove or rename a column or table in one step " \
69
+ "(\"#{unsafe.first.first.strip[0, 90]}\"). Split it: stop using it and add it to " \
70
+ "`ignored_columns`, deploy, then remove it in a later contract step.")
71
+ end
72
+
73
+ # [sentence or code line, the heading it's under] for every change that removes or renames.
74
+ # Headings are only context ("### Removed or renamed columns"), and a negated sentence
75
+ # ("No column is removed") changes nothing.
76
+ def destructive_statements
77
+ statements.select do |statement, _heading, fenced|
78
+ statement.match?(DESTRUCTIVE) && (fenced || !negated?(statement))
79
+ end
80
+ end
81
+
82
+ public
83
+
84
+ # Existing tables the section changes: something is added to them or removed from them.
85
+ # Tables it only compares with ("same as `rsvps`") or names as staying the same
86
+ # ("### Tables not changed") don't count.
87
+ def changed_tables
88
+ removals = destructive_statements.reject { |statement, heading| unchanged?(statement, heading) }
89
+ @schema.tables.select do |table|
90
+ named = /\b#{Regexp.escape(table)}\b/
91
+ paragraph_changing(table).split("\n").any? { |line| !line.match?(UNCHANGED) } ||
92
+ removals.any? { |statement, _| statement.match?(named) }
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def unchanged?(statement, heading)
99
+ statement.match?(UNCHANGED) || heading.to_s.match?(UNCHANGED)
100
+ end
101
+
102
+ # [sentence or code line, the heading above it, inside a code block?] for the whole section.
103
+ def statements
104
+ heading = nil
105
+ fenced = false
106
+ @text.each_line.with_object([]) do |line, found|
107
+ if line.start_with?("```")
108
+ fenced = !fenced
109
+ elsif !fenced && line.match?(/\A\#{1,6}\s/)
110
+ heading = line
111
+ else
112
+ (fenced ? [line] : line.split(/(?<=[.!?])\s+/)).each { |statement| found << [statement, heading, fenced] }
113
+ end
114
+ end
115
+ end
116
+
117
+ # Dropping or changing a table this plan creates touches nothing that runs today.
118
+ def only_new_tables?(statement, created)
119
+ named = statement.scan(/[:`"']([a-z][a-z0-9_]*)\b/).flatten & @schema.tables
120
+ created.any? && named.empty? && created.any? { |table| statement.match?(/\b#{Regexp.escape(table)}\b/) }
121
+ end
47
122
 
48
- report.error("Database changes remove or rename a column or table in one step. Split it: stop using " \
49
- "it and add it to `ignored_columns`, deploy, then remove it in a later migration.")
123
+ # Only a negation before the change counts: "No column is removed", not "Drop it; no code reads it".
124
+ def negated?(statement)
125
+ change = statement =~ /\b(remove|drop|delete|rename|change_column)/i
126
+ change && statement[0, change].match?(NEGATED)
50
127
  end
51
128
 
52
129
  def check_not_null(report)
@@ -129,8 +129,7 @@ module PlanDriven
129
129
  touched = @tickets.flat_map { |ticket| ticket["touches"] }.uniq
130
130
  planned = MigrationGuard.new(@sections["database_changes"].to_s, schema: @schema)
131
131
  new_tables = planned.new_tables
132
- mentioned = @schema.tables.select { |table| @sections["database_changes"].to_s.match?(/\b#{table}\b/) }
133
- (new_tables + mentioned).uniq.each do |table|
132
+ (new_tables + planned.changed_tables).uniq.each do |table|
134
133
  next if touched.include?(table)
135
134
 
136
135
  report.warning("Database changes mention #{table}, but no ticket touches it")
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module PlanDriven
7
+ # The interview as the team changed it, with `plan-driven question` or the wizard's
8
+ # Configuration page. It lives in the application, in config/plan_driven/interview.yml, so it
9
+ # is committed and everyone on the team gets the same questions:
10
+ #
11
+ # questions:
12
+ # who:
13
+ # question: Who owns it, and who reviews the pull requests?
14
+ # success_metric:
15
+ # title: Success metric
16
+ # question: How will we know it worked?
17
+ # group: Overview
18
+ # required: false
19
+ #
20
+ # A key the template already asks changes that question; any other key adds one. Drafted
21
+ # sections belong to the model and can't be changed here.
22
+ module Interview
23
+ PATH = "config/plan_driven/interview.yml"
24
+ KEY = /\A[a-z][a-z0-9_]{1,39}\z/
25
+ FIELDS = %w[title question group required].freeze
26
+ HEADER = "# The plan_driven interview, as changed with `plan-driven question` or the wizard.\n" \
27
+ "# Commit it: everyone on the team gets the same questions.\n"
28
+
29
+ module_function
30
+
31
+ def path(root = PlanDriven.configuration.root_path)
32
+ Pathname(root).join(PATH)
33
+ end
34
+
35
+ def read(root = PlanDriven.configuration.root_path)
36
+ file = path(root)
37
+ return {} unless file.exist?
38
+
39
+ data = YAML.safe_load(file.read) || {}
40
+ questions = data.is_a?(Hash) ? data["questions"] : nil
41
+ questions.is_a?(Hash) ? questions.transform_values { |fields| fields.to_h.slice(*FIELDS) } : {}
42
+ rescue Psych::Exception => e
43
+ raise ConfigurationError, "#{PATH} isn't valid YAML: #{e.message}"
44
+ end
45
+
46
+ # The template with the team's changes applied. Asked sections keep their place; an added
47
+ # question goes after the last section of its group.
48
+ def apply(template, root = PlanDriven.configuration.root_path)
49
+ changes = read(root)
50
+ return template if changes.empty?
51
+
52
+ sections = template.sections.map(&:dup)
53
+ changes.each do |key, fields|
54
+ existing = sections.find { |section| section.key == key }
55
+ next if existing&.drafted?
56
+
57
+ if existing
58
+ update(existing, fields)
59
+ else
60
+ insert(sections, build(key, fields))
61
+ end
62
+ end
63
+ Template.new(sections)
64
+ end
65
+
66
+ # Adds or changes one question. Returns :added or :changed.
67
+ def change(key, fields, template:, root: PlanDriven.configuration.root_path)
68
+ key = validate_key(key, template)
69
+ fields = normalize(fields)
70
+ raise ArgumentError, "Nothing to change: pass --title, --ask, --group, --required or --optional" if fields.empty?
71
+
72
+ changes = read(root)
73
+ changes[key] = changes.fetch(key, {}).merge(fields)
74
+ added = template[key].nil?
75
+ validate_new(changes[key]) if added
76
+ write(changes, root)
77
+ added ? :added : :changed
78
+ end
79
+
80
+ # Removes an added question, or puts a changed one back to the template's default.
81
+ def remove(key, template:, root: PlanDriven.configuration.root_path)
82
+ changes = read(root)
83
+ raise ArgumentError, "#{key} has no changes to remove" unless changes.key?(key.to_s)
84
+
85
+ changes.delete(key.to_s)
86
+ write(changes, root)
87
+ template[key].nil? ? :removed : :reset
88
+ end
89
+
90
+ def origin(key, template:, root: PlanDriven.configuration.root_path)
91
+ return "default" unless read(root).key?(key.to_s)
92
+
93
+ template[key].nil? ? "added" : "changed"
94
+ end
95
+
96
+ def update(section, fields)
97
+ section.title = fields["title"] if fields["title"].to_s.strip != ""
98
+ section.question = fields["question"] if fields["question"].to_s.strip != ""
99
+ section.group = fields["group"] if Template::GROUPS.include?(fields["group"])
100
+ return unless fields.key?("required")
101
+
102
+ section.required = fields["required"] == true
103
+ section.min_words = section.required ? [section.min_words.to_i, 1].max : 0
104
+ end
105
+
106
+ def build(key, fields)
107
+ required = fields["required"] == true
108
+ group = Template::GROUPS.include?(fields["group"]) ? fields["group"] : "Overview"
109
+ Template::Section.new(
110
+ key: key, title: fields["title"].to_s, group: group, source: :ask, required: required,
111
+ min_words: required ? 1 : 0, question: fields["question"].to_s,
112
+ guidance: "The team's answer to: #{fields["question"]}"
113
+ )
114
+ end
115
+
116
+ def normalize(fields)
117
+ fields = fields.to_h.transform_keys(&:to_s).slice(*FIELDS).compact
118
+ %w[title question].each { |name| fields[name] = fields[name].to_s.strip if fields.key?(name) }
119
+ validate_group(fields["group"]) if fields.key?("group")
120
+ fields
121
+ end
122
+
123
+ def validate_new(fields)
124
+ raise ArgumentError, "A new question needs --title" if fields["title"].to_s.empty?
125
+ raise ArgumentError, "A new question needs --ask \"the question\"" if fields["question"].to_s.empty?
126
+ end
127
+
128
+ def insert(sections, section)
129
+ index = sections.rindex { |existing| existing.group == section.group }
130
+ index ? sections.insert(index + 1, section) : sections << section
131
+ end
132
+
133
+ def validate_key(key, template)
134
+ key = key.to_s.strip
135
+ unless key.match?(KEY)
136
+ raise ArgumentError,
137
+ "#{key.inspect} isn't a question key: lower case letters, digits and _, like success_metric"
138
+ end
139
+ if template[key]&.drafted?
140
+ raise ArgumentError, "#{key} is drafted by the model, not asked; only asked questions can be changed"
141
+ end
142
+
143
+ key
144
+ end
145
+
146
+ def validate_group(group)
147
+ return if Template::GROUPS.include?(group)
148
+
149
+ raise ArgumentError, "Unknown group #{group.inspect}. One of: #{Template::GROUPS.join(", ")}"
150
+ end
151
+
152
+ def write(changes, root)
153
+ file = path(root)
154
+ return file.tap { FileUtils.rm_f(file) } if changes.empty?
155
+
156
+ FileUtils.mkdir_p(file.dirname)
157
+ file.write(HEADER + YAML.dump("questions" => changes).delete_prefix("---\n"))
158
+ file
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,254 @@
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
+ # Which coding agents take the tickets: config.agent_provider.
11
+ module Agents
12
+ def self.build(config = PlanDriven.configuration)
13
+ case config.agent_provider.to_sym
14
+ when :cursor then CursorAgents.new(config: config)
15
+ when :local then LocalAgents.new(config: config)
16
+ else raise ConfigurationError, "config.agent_provider is #{config.agent_provider.inspect}; use :cursor or :local"
17
+ end
18
+ end
19
+ end
20
+
21
+ # Runs a coding agent CLI on this machine: Claude Code, Codex, the Cursor CLI, or anything else
22
+ # that edits the files in its working directory. The prompt arrives on stdin, or wherever the
23
+ # command says `{prompt_file}`.
24
+ #
25
+ # config.agent_provider = :local
26
+ # config.agent_command = "claude -p --permission-mode acceptEdits --output-format json"
27
+ # config.agent_command = "codex exec --full-auto -"
28
+ # config.agent_command = 'cursor-agent -p --force --output-format json "$(cat {prompt_file})"'
29
+ #
30
+ # Each ticket gets its own git worktree and branch under tmp/plan_driven/agents. When the
31
+ # command exits cleanly, plan_driven commits what it left, pushes the branch and opens the pull
32
+ # request, so the rest of the workflow (review, feedback, merge) is the same as with Cursor.
33
+ class LocalAgents
34
+ PR_BODY = "PR_DESCRIPTION.md"
35
+ INSTRUCTIONS = <<~TEXT.freeze
36
+ # Working locally
37
+ You are in a git worktree on the ticket's own branch. Don't push and don't open the pull request:
38
+ plan_driven does both when you finish. Write the pull request description (the part described
39
+ above) to `#{PR_BODY}` in the repository root; it becomes the pull request body and isn't committed.
40
+ Committing is optional, anything you leave uncommitted is committed for you.
41
+ TEXT
42
+
43
+ Run = CursorAgents::Run
44
+
45
+ def initialize(config: PlanDriven.configuration, github: nil)
46
+ @config = config
47
+ @github = github
48
+ end
49
+
50
+ def launch(prompt:, repo_url:, name:) # rubocop:disable Lint/UnusedMethodArgument
51
+ command!
52
+ id = "local-#{SecureRandom.hex(4)}"
53
+ state = { "id" => id, "name" => name, "branch" => "plan-driven/#{slug(name)}-#{id.delete_prefix("local-")}",
54
+ "dir" => root.join(id).to_s, "runs" => [] }
55
+ git(@config.root_path, "fetch", "--quiet", "origin", @config.base_branch)
56
+ git(@config.root_path, "worktree", "add", "--quiet", "-b", state["branch"], state["dir"],
57
+ "origin/#{@config.base_branch}")
58
+ run = start(state, "#{prompt}\n\n#{INSTRUCTIONS}")
59
+ [{ "id" => id, "url" => nil }, run]
60
+ end
61
+
62
+ def run(agent_id, run_id)
63
+ state = load(agent_id)
64
+ entry = state["runs"].find { |item| item["id"] == run_id } or raise ProviderError, "no local run #{run_id}"
65
+ return to_run(state, entry) if entry["status"]
66
+
67
+ exit_file = Pathname(entry["exit_file"])
68
+ if !exit_file.exist? && alive?(entry["pid"])
69
+ return to_run(state, entry, "RUNNING") unless overdue?(entry)
70
+
71
+ stop(entry["pid"])
72
+ return settle(state, entry, "EXPIRED", "stopped after #{@config.agent_timeout}s (config.agent_timeout)")
73
+ end
74
+
75
+ code = exit_file.exist? ? exit_file.read.strip.to_i : 1
76
+ unless code.zero?
77
+ return settle(state, entry, "ERROR",
78
+ "the agent command exited with #{code}: #{log_tail(entry)}")
79
+ end
80
+
81
+ publish(state, entry)
82
+ end
83
+
84
+ def follow_up(agent_id, text)
85
+ state = load(agent_id)
86
+ start(state, "#{text}\n\n#{INSTRUCTIONS}")
87
+ end
88
+
89
+ # Tokens, when the command reports them the way Claude Code's `--output-format json` does.
90
+ def usage(agent_id, run_id)
91
+ entry = load(agent_id)["runs"].find { |item| item["id"] == run_id }
92
+ data = entry && reported_usage(Pathname(entry["log"]))
93
+ return {} unless data
94
+
95
+ { "input_tokens" => data["input_tokens"].to_i, "output_tokens" => data["output_tokens"].to_i,
96
+ "cache_write_tokens" => data["cache_creation_input_tokens"].to_i,
97
+ "cache_read_tokens" => data["cache_read_input_tokens"].to_i }
98
+ end
99
+
100
+ # Once the pull request is merged, the worktree and its local branch go.
101
+ def cleanup(agent_id)
102
+ state = load(agent_id)
103
+ git(@config.root_path, "worktree", "remove", "--force", state["dir"]) if Dir.exist?(state["dir"])
104
+ git_ok?(@config.root_path, "branch", "-D", state["branch"])
105
+ rescue ProviderError
106
+ nil
107
+ end
108
+
109
+ def me
110
+ { "apiKeyName" => "local: #{command!}" }
111
+ end
112
+
113
+ def model_ids
114
+ []
115
+ end
116
+
117
+ private
118
+
119
+ def start(state, prompt)
120
+ number = state["runs"].size + 1
121
+ base = root.join("#{state["id"]}-run-#{number}")
122
+ File.write("#{base}.prompt", prompt)
123
+ prompt_file = Shellwords.escape("#{base}.prompt")
124
+ agent = if command!.include?("{prompt_file}")
125
+ "#{command!.gsub("{prompt_file}", prompt_file)} < /dev/null"
126
+ else
127
+ "#{command!} < #{prompt_file}"
128
+ end
129
+ script = "#{agent} > #{Shellwords.escape("#{base}.log")} 2>&1; echo $? > #{Shellwords.escape("#{base}.exit")}"
130
+ pid = Process.spawn("sh", "-c", script, chdir: state["dir"], pgroup: true, in: File::NULL)
131
+ Process.detach(pid)
132
+ entry = { "id" => "#{state["id"]}-run-#{number}", "pid" => pid, "started_at" => Time.now.to_i,
133
+ "log" => "#{base}.log", "exit_file" => "#{base}.exit" }
134
+ state["runs"] << entry
135
+ save(state)
136
+ to_run(state, entry, "RUNNING")
137
+ end
138
+
139
+ # Commit, push, and open the pull request the first time; later runs push to the same one.
140
+ def publish(state, entry)
141
+ dir = Pathname(state["dir"])
142
+ body = commit_all(dir, state["name"])
143
+ if git(dir, "rev-list", "--count", "origin/#{@config.base_branch}..HEAD").strip == "0"
144
+ return settle(state, entry, "ERROR", "the agent finished without changing anything: #{log_tail(entry)}")
145
+ end
146
+
147
+ git(dir, "push", "--quiet", "--set-upstream", "origin", state["branch"])
148
+ state["pr_url"] ||= github.create_pull(title: state["name"], head: state["branch"], base: @config.base_branch,
149
+ body: body || state["name"])["html_url"]
150
+ settle(state, entry, "FINISHED", body.to_s[0, 500])
151
+ rescue ProviderError => e
152
+ settle(state, entry, "ERROR", e.message)
153
+ end
154
+
155
+ # Commits what the agent left, without its pull request description, which is returned.
156
+ def commit_all(dir, message)
157
+ body_file = dir.join(PR_BODY)
158
+ body = body_file.exist? ? body_file.read : nil
159
+ FileUtils.rm_f(body_file)
160
+ git(dir, "add", "--all")
161
+ git(dir, "commit", "--quiet", "-m", message) unless git_ok?(dir, "diff", "--cached", "--quiet")
162
+ body
163
+ end
164
+
165
+ def settle(state, entry, status, result)
166
+ entry["status"] = status
167
+ entry["result"] = result
168
+ entry["duration_ms"] = (Time.now.to_i - entry["started_at"]) * 1000
169
+ save(state)
170
+ to_run(state, entry)
171
+ end
172
+
173
+ def to_run(state, entry, status = entry["status"])
174
+ Run.new(id: entry["id"], agent_id: state["id"], status: status, result: entry["result"],
175
+ branch: state["branch"], pr_url: status == "FINISHED" ? state["pr_url"] : nil,
176
+ duration_ms: entry["duration_ms"])
177
+ end
178
+
179
+ def reported_usage(log)
180
+ return unless log.exist?
181
+
182
+ log.read.lines.reverse_each do |line|
183
+ data = JSON.parse(line)
184
+ return data["usage"] if data.is_a?(Hash) && data["usage"].is_a?(Hash)
185
+ rescue JSON::ParserError
186
+ next
187
+ end
188
+ nil
189
+ end
190
+
191
+ def command!
192
+ @config.agent_command.presence or
193
+ raise ConfigurationError, "config.agent_provider is :local, so set config.agent_command, for example " \
194
+ "\"claude -p --permission-mode acceptEdits --output-format json\""
195
+ end
196
+
197
+ def git(dir, *args)
198
+ out, status = Open3.capture2e("git", *args, chdir: dir.to_s)
199
+ raise ProviderError, "git #{args.first} failed: #{out.strip.last(300)}" unless status.success?
200
+
201
+ out
202
+ end
203
+
204
+ def git_ok?(dir, *args)
205
+ _out, status = Open3.capture2e("git", *args, chdir: dir.to_s)
206
+ status.success?
207
+ end
208
+
209
+ def alive?(pid)
210
+ Process.kill(0, pid)
211
+ true
212
+ rescue Errno::ESRCH, Errno::EPERM
213
+ false
214
+ end
215
+
216
+ def overdue?(entry)
217
+ Time.now.to_i - entry["started_at"] > @config.agent_timeout.to_i
218
+ end
219
+
220
+ def stop(pid)
221
+ Process.kill("TERM", -pid)
222
+ rescue Errno::ESRCH, Errno::EPERM
223
+ nil
224
+ end
225
+
226
+ def log_tail(entry)
227
+ log = Pathname(entry["log"])
228
+ log.exist? ? log.read.strip.last(300) : "no output"
229
+ end
230
+
231
+ def slug(name)
232
+ name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")[0, 40]
233
+ end
234
+
235
+ def root
236
+ @config.root_path.join("tmp/plan_driven/agents").tap { |dir| FileUtils.mkdir_p(dir) }
237
+ end
238
+
239
+ def load(agent_id)
240
+ path = root.join("#{agent_id}.json")
241
+ raise ProviderError, "no local agent #{agent_id} (#{path} is missing)" unless path.exist?
242
+
243
+ JSON.parse(path.read)
244
+ end
245
+
246
+ def save(state)
247
+ File.write(root.join("#{state["id"]}.json"), JSON.pretty_generate(state))
248
+ end
249
+
250
+ def github
251
+ @github ||= GitHub.new
252
+ end
253
+ end
254
+ end
@@ -7,10 +7,14 @@ module PlanDriven
7
7
  # and doesn't need to be.
8
8
  module HTML
9
9
  STYLE = File.read(File.expand_path("style.css", __dir__))
10
+ IMAGE = /\A!\[([^\]]*)\]\(([^)\s]+)\)\z/
11
+ # A table cell that is only a result becomes a coloured pill, and colours its row.
12
+ RESULT = /\A(?:#{Evidence::MARKS.values.map { |mark| Regexp.escape(mark) }.join("|")})?\s*
13
+ (passed|failed|not\ run|no\ scenario|merged)\z/x
10
14
 
11
15
  module_function
12
16
 
13
- def document(markdown, title:)
17
+ def document(markdown, title:, images: {})
14
18
  <<~HTML
15
19
  <!doctype html>
16
20
  <html lang="en">
@@ -21,19 +25,22 @@ module PlanDriven
21
25
  </head>
22
26
  <body>
23
27
  <main>
24
- #{convert(markdown)}
28
+ #{convert(markdown, images: images)}
25
29
  </main>
26
30
  </body>
27
31
  </html>
28
32
  HTML
29
33
  end
30
34
 
31
- def convert(markdown)
35
+ def convert(markdown, images: {})
32
36
  lines = markdown.to_s.lines.map(&:chomp)
33
37
  out = []
34
38
  until lines.empty?
35
39
  line = lines.first
36
- if line.start_with?("```")
40
+ if (image = line.strip.match(IMAGE))
41
+ lines.shift
42
+ out << figure(image[1], image[2], images)
43
+ elsif line.start_with?("```")
37
44
  out << code_block(lines)
38
45
  elsif line.match?(/\A\s*\|/)
39
46
  out << table(take_while(lines) { |l| l.match?(/\A\s*\|/) })
@@ -72,10 +79,32 @@ module PlanDriven
72
79
  header, *body = cells
73
80
  body = body.reject { |row| row.all? { |cell| cell.match?(/\A:?-+:?\z/) } }
74
81
  head = header.map { |cell| "<th>#{inline(cell)}</th>" }.join
75
- rows = body.map { |row| "<tr>#{row.map { |cell| "<td>#{inline(cell.gsub("\\|", "|"))}</td>" }.join}</tr>" }
82
+ rows = body.map { |row| table_row(row) }
76
83
  "<table><thead><tr>#{head}</tr></thead><tbody>#{rows.join}</tbody></table>"
77
84
  end
78
85
 
86
+ def table_row(row)
87
+ results = row.filter_map { |cell| cell.match(RESULT)&.[](1) }
88
+ cells = row.map do |cell|
89
+ result = cell.match(RESULT)&.[](1)
90
+ content = if result
91
+ %(<span class="result #{result.tr(" ",
92
+ "-")}">#{result}</span>)
93
+ else
94
+ inline(cell.gsub("\\|", "|"))
95
+ end
96
+ "<td>#{content}</td>"
97
+ end
98
+ klass = results.last ? %( class="#{results.last.tr(" ", "-")}") : ""
99
+ "<tr#{klass}>#{cells.join}</tr>"
100
+ end
101
+
102
+ # A chart written next to the document is inlined, so the HTML and PDF stand alone.
103
+ def figure(alt, src, images)
104
+ body = images[src] || %(<img src="#{CGI.escapeHTML(src)}" alt="#{CGI.escapeHTML(alt)}">)
105
+ %(<figure class="chart">#{body}</figure>)
106
+ end
107
+
79
108
  def list(lines)
80
109
  ordered = lines.first.match?(/\A\s*\d+\./)
81
110
  items = []