openlanes 3.0.0 → 3.0.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/agents/c3po-operator.md +58 -0
  3. data/lib/openlanes/adapters/base.rb +7 -0
  4. data/lib/openlanes/adapters/claude.rb +21 -2
  5. data/lib/openlanes/adapters.rb +19 -2
  6. data/lib/openlanes/cli/run/run.rb +47 -15
  7. data/lib/openlanes/cli/start/start.rb +1 -14
  8. data/lib/openlanes/cli/web/web.rb +11 -1
  9. data/lib/openlanes/engine/dispatcher.rb +62 -7
  10. data/lib/openlanes/engine/narrator.rb +107 -0
  11. data/lib/openlanes/engine/progress_log.rb +50 -9
  12. data/lib/openlanes/execution/prompt.rb +13 -0
  13. data/lib/openlanes/lifecycle/creator.rb +24 -0
  14. data/lib/openlanes/live/chat.rb +616 -0
  15. data/lib/openlanes/live/commands.rb +4 -1
  16. data/lib/openlanes/live/copilot/brief.rb +128 -0
  17. data/lib/openlanes/live/copilot/cards.rb +226 -0
  18. data/lib/openlanes/live/copilot/operator.rb +259 -0
  19. data/lib/openlanes/live/copilot.rb +256 -0
  20. data/lib/openlanes/live/keys.rb +58 -0
  21. data/lib/openlanes/live/memory_store.rb +24 -0
  22. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  23. data/lib/openlanes/live/session.rb +7 -1
  24. data/lib/openlanes/live/stations.rb +9 -5
  25. data/lib/openlanes/live/store.rb +18 -0
  26. data/lib/openlanes/live/subscriber.rb +73 -0
  27. data/lib/openlanes/live.rb +5 -0
  28. data/lib/openlanes/presentation/reporter.rb +2 -2
  29. data/lib/openlanes/support/ui.rb +75 -1
  30. data/lib/openlanes/vcs/publisher.rb +62 -0
  31. data/lib/openlanes/vcs/worktree.rb +44 -9
  32. data/lib/openlanes/version.rb +1 -1
  33. data/lib/openlanes/web/app.rb +100 -1
  34. data/lib/openlanes/web/context.rb +20 -2
  35. data/lib/openlanes/web/demo.rb +10 -0
  36. data/lib/openlanes/web/events.rb +51 -2
  37. data/lib/openlanes/web/launcher.rb +1 -1
  38. data/lib/openlanes/web/server.rb +2 -0
  39. data/lib/openlanes/web/stores.rb +11 -0
  40. data/lib/openlanes/web.rb +13 -0
  41. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  42. data/web/dist/assets/{index-BdR34cXC.js → index-6MZkmh3k.js} +2 -2
  43. data/web/dist/assets/{index-uM88Njdj.js → index-B5CKJPm-.js} +2 -2
  44. data/web/dist/assets/{index-DB4QwuYn.js → index-BF6QYroq.js} +2 -2
  45. data/web/dist/assets/{index-Bp7W-ZTq.js → index-BFPI-zXI.js} +2 -2
  46. data/web/dist/assets/{index-CI74IkWP.js → index-BYJCWlk0.js} +49 -49
  47. data/web/dist/assets/{index-popk9wbW.js → index-C2jwoWVV.js} +2 -2
  48. data/web/dist/assets/{index-CYxcbpRM.js → index-C3zc4gFe.js} +2 -2
  49. data/web/dist/assets/index-CjInBi5X.css +1 -0
  50. data/web/dist/assets/{index-2xPC-eHt.js → index-CnJfB0l2.js} +2 -2
  51. data/web/dist/assets/{index-P3vSkiU-.js → index-CnvkZu5Y.js} +2 -2
  52. data/web/dist/assets/{index-OR-odsl9.js → index-DH0gDlee.js} +2 -2
  53. data/web/dist/assets/{index-D0rmJN3h.js → index-DXgeajd6.js} +2 -2
  54. data/web/dist/assets/{index-C0VmkrO-.js → index-DaSjQ-H4.js} +2 -2
  55. data/web/dist/assets/{index-B7-O9ujQ.js → index-DbueqOK5.js} +2 -2
  56. data/web/dist/assets/{index-DDI7zoAS.js → index-Dbxvk-EC.js} +2 -2
  57. data/web/dist/assets/{index-8dNhRmiO.js → index-DtA46i32.js} +2 -2
  58. data/web/dist/assets/{index-DGLUuEXi.js → index-IqD8mBos.js} +2 -2
  59. data/web/dist/assets/{index-Byyxbh_z.js → index-bpsFIpXE.js} +2 -2
  60. data/web/dist/assets/{index-mRV4XhJZ.js → index-pvJi-JJO.js} +2 -2
  61. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  62. data/web/dist/index.html +3 -3
  63. metadata +31 -23
  64. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  65. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  66. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  67. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "tmpdir"
5
+
3
6
  module OpenLanes
4
7
  module Engine
5
8
  # One line of `run --log FILE`, laid out as fixed-width columns.
@@ -19,8 +22,7 @@ module OpenLanes
19
22
  # @example
20
23
  # ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
21
24
  # agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
22
-
23
- # #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
25
+ # #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
24
26
  module ProgressLog
25
27
  # Wall clock only. A log line answers "when", and the date is the file's.
26
28
  TIME_FORMAT = "%H:%M:%S"
@@ -31,12 +33,17 @@ module OpenLanes
31
33
  # "003.00".
32
34
  PLAN_WIDTH = 6
33
35
 
34
- # Derived from the widest state as {Status#to_s} renders it, emoji
35
- # included, so a state added to {STATUSES} with a longer label cannot
36
- # silently knock the columns after it out of line — then trimmed by five
37
- # cells: the emoji already names the state, so the longest labels can
38
- # afford to lose their tails to keep the line short.
39
- STATUS_WIDTH = Plans::STATUSES.map { |status| UI.display_width(status.to_s) }.max - 5
36
+ # Cells given to the emoji, and the gap after it. Many terminal fonts draw
37
+ # a colour emoji wider than its two cells, and with one space it overlapped
38
+ # the label's first letter ("🔴Changes").
39
+ EMOJI_WIDTH = 2
40
+ EMOJI_GAP = " "
41
+
42
+ # Derived from the widest state as {.status_cell} renders it, so a state
43
+ # added to {STATUSES} with a longer label cannot silently knock the columns
44
+ # after it out of line. Untrimmed: cut to fit, "Changes Requested" and
45
+ # "Ready for Planning" read as "Changes Reque" and "Ready for Pla".
46
+ STATUS_WIDTH = Plans::STATUSES.map { |status| EMOJI_WIDTH + EMOJI_GAP.length + UI.display_width(status.label) }.max
40
47
 
41
48
  # The longest specialist name in `agents/` is `palpatine-planner` at 17,
42
49
  # and the names are hyphenated words rather than a bounded vocabulary, so
@@ -54,6 +61,10 @@ module OpenLanes
54
61
  # is still waiting on.
55
62
  SECONDS_WIDTH = 6
56
63
 
64
+ # The keywords {.render} takes besides the message. {UI.log} hands its
65
+ # taps more than these, and the file only these.
66
+ FIELDS = %i[plan status agent seconds round pid at].freeze
67
+
57
68
  # Between columns, inside the brackets.
58
69
  SEPARATOR = " | "
59
70
 
@@ -67,7 +78,25 @@ module OpenLanes
67
78
  # @see COLUMN_WIDTHS
68
79
  LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
69
80
 
81
+ # Hex digits of the checkout's path in a default log's name.
82
+ CHECKOUT_DIGITS = 6
83
+
70
84
  class << self
85
+ # Where a run's log goes when `--log` names nowhere: the temp
86
+ # directory, under the checkout's directory name and a short digest
87
+ # of its full path. The name alone collided: `~/a/minesweeper` and
88
+ # `~/b/minesweeper` both appended to `openlanes-minesweeper.log`, and
89
+ # two runs' lines read as one.
90
+ #
91
+ # @param root [String] the checkout
92
+ # @param kind [String, nil] which process logs, e.g. "web"
93
+ # @return [String] e.g. "/tmp/openlanes-minesweeper-3f9a1c.log"
94
+ def default_path(root, kind: nil)
95
+ root = File.expand_path(root)
96
+ digest = Digest::SHA256.hexdigest(root)[0, CHECKOUT_DIGITS]
97
+ File.join(Dir.tmpdir, "#{["openlanes", kind, File.basename(root), digest].compact.join("-")}.log")
98
+ end
99
+
71
100
  # Render one log line: bracketed fixed-width columns, then the message.
72
101
  #
73
102
  # No colour. The line goes to a file, where an escape sequence is neither
@@ -87,7 +116,7 @@ module OpenLanes
87
116
  columns = [
88
117
  left(at.strftime(TIME_FORMAT), TIME_WIDTH),
89
118
  left(plan, PLAN_WIDTH),
90
- left(status, STATUS_WIDTH),
119
+ left(status_cell(status), STATUS_WIDTH),
91
120
  left(agent, AGENT_WIDTH),
92
121
  right(round, ROUND_WIDTH),
93
122
  right(pid, PID_WIDTH),
@@ -99,6 +128,18 @@ module OpenLanes
99
128
  text.empty? ? prefix : "#{prefix} #{text}"
100
129
  end
101
130
 
131
+ # The emoji fitted to {EMOJI_WIDTH} and followed by {EMOJI_GAP}, then the
132
+ # label. A value with no label (or none at all) passes through.
133
+ #
134
+ # @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
135
+ # @return [String, nil]
136
+ def status_cell(status)
137
+ emoji, label = status.to_s.split(" ", 2)
138
+ return status if label.nil?
139
+
140
+ "#{UI.fit(emoji, EMOJI_WIDTH)}#{EMOJI_GAP}#{label}"
141
+ end
142
+
102
143
  # @param seconds [Numeric, nil]
103
144
  # @return [String] whole seconds with a trailing "s", or blank for nil
104
145
  def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
@@ -65,6 +65,7 @@ module OpenLanes
65
65
  Repository root: #{root}
66
66
 
67
67
  #{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
68
+ #{snapshot_note}
68
69
  #{operator_instructions}#{requirements_section}#{completion_section}#{suggestions_section}#{ledger_section(agent, subject, round:, successor:)}#{budget_section}#{time_budget_section(agent)}#{control_section(control, agent, subject)}#{questions_section}#{mailbox_section(agent, subject, partners)}
69
70
  ## Boundary — enforced, not requested
70
71
 
@@ -122,6 +123,18 @@ module OpenLanes
122
123
 
123
124
  attr_reader :agent, :subject, :root, :profile, :round, :successor, :partners, :control
124
125
 
126
+ # In a worktree the repository root holds its own `.plans`, a snapshot
127
+ # the publisher refreshes for the pull request. An agent that edited or
128
+ # signed it would be writing to a copy the next publish throws away.
129
+ #
130
+ # @return [String, nil]
131
+ def snapshot_note
132
+ return nil if File.expand_path(subject.feature.path).start_with?("#{File.expand_path(root)}/")
133
+
134
+ "The plan lives only at the plan folder above. Any `.plans` under the repository root is a\n" \
135
+ "snapshot the harness overwrites when it publishes: never read the plan from it or write to it."
136
+ end
137
+
125
138
  # Only a CLI that actually refuses the commands may be described as
126
139
  # refusing them.
127
140
  #
@@ -61,6 +61,30 @@ module OpenLanes
61
61
  end
62
62
  end
63
63
 
64
+ # How many words of an idea name its plan.
65
+ TOPIC_WORDS = 8
66
+
67
+ # The words a free-form idea's plan is named after: a frontmatter
68
+ # `title:`, else its first line, a heading's `#` marks dropped.
69
+ #
70
+ # Taking the first words of the raw text named the minesweeper trial's
71
+ # plan `minesweeper-minesweeper-is-played-on-a-grid`: the idea opened
72
+ # with `# Minesweeper` and then a paragraph starting with the same
73
+ # word, and the heading's `#` was dropped while its word was kept.
74
+ #
75
+ # @param idea [String] Markdown, as the page's idea dialog sends it
76
+ # @return [Array<String>] at most {TOPIC_WORDS} words, possibly none
77
+ def self.topic(idea)
78
+ meta, body = begin
79
+ Frontmatter.split(idea.to_s)
80
+ rescue Psych::Exception
81
+ [{}, idea.to_s.sub(Frontmatter::PATTERN, '\\2')]
82
+ end
83
+ title = meta["title"].to_s.strip
84
+ line = title.empty? ? body.lines.map(&:strip).find { !it.empty? }.to_s : title
85
+ line.sub(/\A\#{1,6}\s+/, "").split.first(TOPIC_WORDS)
86
+ end
87
+
64
88
  # Turn free text into the kebab tail of a folder name.
65
89
  #
66
90
  # @param words [Array<String>, String]