cafe_car 0.1.1 → 0.1.2

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +155 -40
  3. data/Rakefile +9 -1
  4. data/app/assets/fonts/Lexend.css +7 -0
  5. data/app/assets/fonts/Lexend.ttf +0 -0
  6. data/app/assets/stylesheets/cafe_car/themes/defaults.css +58 -59
  7. data/app/assets/stylesheets/cafe_car/tooltips.css +20 -0
  8. data/app/assets/stylesheets/cafe_car/utility.css +1 -2
  9. data/app/assets/stylesheets/cafe_car.css +17 -6
  10. data/app/assets/stylesheets/ui/Alert.css +2 -1
  11. data/app/assets/stylesheets/ui/Button.css +6 -6
  12. data/app/assets/stylesheets/ui/Card.css +7 -3
  13. data/app/assets/stylesheets/ui/Chat.css +33 -0
  14. data/app/assets/stylesheets/ui/Close.css +11 -0
  15. data/app/assets/stylesheets/ui/Grid.css +2 -0
  16. data/app/assets/stylesheets/ui/Icon.css +3 -3
  17. data/app/assets/stylesheets/ui/Layout.css +20 -13
  18. data/app/assets/stylesheets/ui/Modal.css +5 -12
  19. data/app/assets/stylesheets/ui/Navigation.css +13 -5
  20. data/app/assets/stylesheets/ui/Page.css +42 -3
  21. data/app/assets/stylesheets/ui/Table.css +27 -56
  22. data/app/assets/stylesheets/ui/components.css +2 -0
  23. data/app/controllers/cafe_car/examples_controller.rb +1 -1
  24. data/app/controllers/cafe_car/sessions_controller.rb +30 -0
  25. data/app/controllers/concerns/cafe_car/authentication.rb +61 -0
  26. data/app/javascript/cafe_car.js +16 -11
  27. data/app/models/cafe_car/session.rb +18 -0
  28. data/app/policies/cafe_car/session_policy.rb +19 -0
  29. data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +5 -4
  30. data/app/presenters/cafe_car/code_presenter.rb +18 -0
  31. data/app/presenters/cafe_car/date_presenter.rb +1 -0
  32. data/app/presenters/cafe_car/date_time_presenter.rb +2 -2
  33. data/app/presenters/cafe_car/enumerable_presenter.rb +1 -1
  34. data/app/presenters/cafe_car/hash_presenter.rb +3 -8
  35. data/app/presenters/cafe_car/presenter.rb +22 -12
  36. data/app/presenters/cafe_car/string_presenter.rb +2 -2
  37. data/app/ui/cafe_car/ui/button.rb +2 -1
  38. data/app/ui/cafe_car/ui/card.rb +18 -0
  39. data/app/ui/cafe_car/ui/grid.rb +30 -0
  40. data/app/ui/cafe_car/ui/layout.rb +7 -0
  41. data/app/ui/cafe_car/ui/page.rb +5 -1
  42. data/app/views/application/_body.html.haml +2 -1
  43. data/app/views/application/_controls.html.haml +1 -0
  44. data/app/views/application/_debug.html.haml +9 -2
  45. data/app/views/application/_errors.html.haml +4 -8
  46. data/app/views/application/_grid.html.haml +1 -1
  47. data/app/views/application/_grid_item.html.haml +1 -1
  48. data/app/views/application/_head.html.haml +1 -0
  49. data/app/views/application/_index.html.haml +6 -2
  50. data/app/views/application/_index_actions.html.haml +3 -3
  51. data/app/views/application/_navigation.html.haml +7 -0
  52. data/app/views/application/_navigation_links.html.haml +1 -1
  53. data/app/views/application/_notes.html.haml +1 -0
  54. data/app/views/application/_popup.html.haml +7 -0
  55. data/app/views/cafe_car/application/edit.html.haml +1 -1
  56. data/app/views/cafe_car/application/edit.turbo_stream.haml +3 -5
  57. data/app/views/cafe_car/application/index.html.haml +3 -0
  58. data/app/views/cafe_car/application/new.turbo_stream.haml +5 -6
  59. data/app/views/cafe_car/application/show.html.haml +2 -2
  60. data/app/views/cafe_car/examples/ui/_chat.html.haml +3 -0
  61. data/app/views/cafe_car/examples/ui/_info_circle.html.haml +1 -1
  62. data/app/views/cafe_car/examples/ui/_modal.html.haml +1 -1
  63. data/app/views/passwords_mailer/reset.html.haml +5 -0
  64. data/app/views/passwords_mailer/reset.text.erb +4 -0
  65. data/app/views/ui/_card.html.haml +6 -11
  66. data/app/views/ui/_field.html.haml +1 -7
  67. data/app/views/ui/_modal_close.html.haml +1 -2
  68. data/app/views/ui/_page.html.haml +6 -12
  69. data/config/brakeman.ignore +3 -3
  70. data/config/locales/en.yml +10 -2
  71. data/config/routes.rb +5 -1
  72. data/db/migrate/20251005220017_create_slugs.rb +2 -2
  73. data/lib/cafe_car/active_record.rb +1 -1
  74. data/lib/cafe_car/application_responder.rb +6 -0
  75. data/lib/cafe_car/attributes.rb +1 -1
  76. data/lib/cafe_car/auto_resolver.rb +1 -1
  77. data/lib/cafe_car/component.rb +102 -39
  78. data/lib/cafe_car/context.rb +5 -4
  79. data/lib/cafe_car/controller/filtering.rb +9 -1
  80. data/lib/cafe_car/controller.rb +52 -13
  81. data/lib/cafe_car/core_ext/array.rb +13 -0
  82. data/lib/cafe_car/core_ext/hash.rb +15 -0
  83. data/lib/cafe_car/core_ext/module.rb +15 -0
  84. data/lib/cafe_car/core_ext.rb +0 -2
  85. data/lib/cafe_car/current.rb +4 -1
  86. data/lib/cafe_car/engine.rb +9 -2
  87. data/lib/cafe_car/field_builder.rb +1 -1
  88. data/lib/cafe_car/field_info.rb +14 -12
  89. data/lib/cafe_car/fields.rb +7 -0
  90. data/lib/cafe_car/filter/field_info.rb +1 -1
  91. data/lib/cafe_car/filter/form_builder.rb +2 -2
  92. data/lib/cafe_car/filter_builder.rb +1 -1
  93. data/lib/cafe_car/form_builder.rb +1 -1
  94. data/lib/cafe_car/generators.rb +1 -1
  95. data/lib/cafe_car/helpers.rb +37 -10
  96. data/lib/cafe_car/href_builder.rb +35 -9
  97. data/lib/cafe_car/input_builder.rb +1 -1
  98. data/lib/cafe_car/link_builder.rb +14 -11
  99. data/lib/cafe_car/model.rb +2 -2
  100. data/lib/cafe_car/navigation.rb +10 -10
  101. data/lib/cafe_car/option_helpers.rb +11 -5
  102. data/lib/cafe_car/param_parser.rb +10 -6
  103. data/lib/cafe_car/policy.rb +2 -2
  104. data/lib/cafe_car/query_builder.rb +3 -3
  105. data/lib/cafe_car/resolver.rb +5 -1
  106. data/lib/cafe_car/routing.rb +1 -1
  107. data/lib/cafe_car/table/builder.rb +3 -2
  108. data/lib/cafe_car/table/head_builder.rb +2 -2
  109. data/lib/cafe_car/table/label_builder.rb +1 -1
  110. data/lib/cafe_car/table/row_builder.rb +5 -7
  111. data/lib/cafe_car/table_builder.rb +3 -3
  112. data/lib/cafe_car/ui.rb +2 -0
  113. data/lib/cafe_car/version.rb +1 -1
  114. data/lib/cafe_car/visitors.rb +2 -2
  115. data/lib/cafe_car.rb +25 -0
  116. data/lib/generators/cafe_car/controller/templates/controller.rb.tt +1 -1
  117. data/lib/generators/cafe_car/install/install_generator.rb +0 -1
  118. data/lib/generators/cafe_car/sessions/USAGE +17 -0
  119. data/lib/generators/cafe_car/sessions/sessions_generator.rb +29 -0
  120. data/lib/generators/cafe_car/sessions/templates/create_sessions.rb.tt +12 -0
  121. data/lib/tasks/holdco_tasks.rake +532 -0
  122. data/lib/tasks/templates/tasks_header.md +37 -0
  123. metadata +76 -48
  124. data/app/models/cafe_car/slug.rb +0 -3
  125. data/app/views/ui/_grid.html.haml +0 -17
  126. data/app/views/ui/_layout_menu.html.haml +0 -2
@@ -0,0 +1,17 @@
1
+ Description:
2
+ Enables CafeCar's opt-in login/logout by creating the `sessions` table.
3
+
4
+ The Session model (CafeCar::Session) and SessionPolicy ship with the
5
+ engine, so this generator does NOT create a model or policy -- it only
6
+ adds the migration for the table they need.
7
+
8
+ Example:
9
+ bin/rails generate cafe_car:sessions
10
+
11
+ This creates:
12
+ db/migrate/XXXXXXXX_create_sessions.rb (user, ip_address, user_agent)
13
+
14
+ After running, migrate and expose the session routes (mounting the engine
15
+ does this automatically, or add `resource :session, only: %i[new create
16
+ destroy], controller: "cafe_car/sessions"`). If your user model isn't
17
+ `User`, set `CafeCar.user_class_name` in an initializer.
@@ -0,0 +1,29 @@
1
+ class CafeCar::SessionsGenerator < Rails::Generators::Base
2
+ include CafeCar::Generators
3
+
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def create_sessions
7
+ migration "create_sessions"
8
+ end
9
+
10
+ def show_readme
11
+ return unless behavior == :invoke
12
+
13
+ say <<~MSG
14
+
15
+ CafeCar sessions are enabled. The Session model and SessionPolicy ship
16
+ with the engine, so all this added is the `sessions` table.
17
+
18
+ Next:
19
+ - Run `bin/rails db:migrate`.
20
+ - Make sure your user model has `has_secure_password` and an `email`.
21
+ - Mounting the engine exposes /session (login). To expose it without
22
+ mounting, add to config/routes.rb:
23
+ resource :session, only: %i[new create destroy],
24
+ controller: "cafe_car/sessions"
25
+ - If your user model isn't `User`, set CafeCar.user_class_name in an
26
+ initializer (e.g. `CafeCar.user_class_name = "Account"`).
27
+ MSG
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ # Generated via `rails generate cafe_car:sessions`
2
+ class CreateSessions < ActiveRecord::Migration[8.1]
3
+ def change
4
+ create_table :sessions do |t|
5
+ t.references :user, null: false, foreign_key: true
6
+ t.string :ip_address
7
+ t.string :user_agent
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,532 @@
1
+ # One-file-per-task backlog. Each task is a markdown file with YAML frontmatter
2
+ # under `tasks/`, so parallel agents that create/finish *different* tasks touch
3
+ # *different* files and never race on a push. `TASKS.md` is a GENERATED index
4
+ # (rake tasks:index) — don't hand-edit it.
5
+ #
6
+ # Frontmatter schema (see tasks/_template.md):
7
+ # id stable, sortable slug (also the filename: <id>.md)
8
+ # title short imperative title
9
+ # priority P0 | P1 | P2 | untriaged (untriaged = created via `rake task`, awaiting triage)
10
+ # status open | wip | blocked | done
11
+ # domain Eng | Design | Product | Marketing | Finance | Legal | Ops | Launch-blocking | untriaged
12
+ # created YYYY-MM-DD
13
+ # updated YYYY-MM-DD (optional)
14
+ # blocked_on user (optional flag for user-blocked items)
15
+ # The markdown body holds the full description + sub-bullets, verbatim.
16
+
17
+ require "yaml"
18
+ require "date"
19
+ require "fileutils"
20
+
21
+ # Standalone (no ActiveSupport): minimal `String#presence` used by the editor flow.
22
+ # Guarded so it's a no-op if this ever loads alongside Rails.
23
+ class String
24
+ def presence = strip.empty? ? nil : self
25
+ end unless String.method_defined?(:presence)
26
+
27
+ namespace :tasks do
28
+ # Guard constants against redefinition warnings — Rails engine.rake also loads
29
+ # this file under the app:* namespace, so these constants would otherwise be
30
+ # set twice on the same Ruby process.
31
+ TASKS_DIR = "tasks".freeze unless defined?(TASKS_DIR)
32
+ INDEX_FILE = "TASKS.md".freeze unless defined?(INDEX_FILE)
33
+ HEADER_FILE = "lib/tasks/templates/tasks_header.md".freeze unless defined?(HEADER_FILE)
34
+
35
+ # Domains in render order. Index sections appear in this order.
36
+ DOMAINS = %w[Launch-blocking Eng Design Product Marketing Finance Legal Ops].freeze unless defined?(DOMAINS)
37
+
38
+ # Map a TASKS.md "## emoji Heading" (normalized) to a domain label.
39
+ DOMAIN_FROM_HEADING = {
40
+ "launch-blocking" => "Launch-blocking",
41
+ "engineering" => "Eng",
42
+ "design" => "Design",
43
+ "product" => "Product",
44
+ "marketing & gtm" => "Marketing",
45
+ "marketing" => "Marketing",
46
+ "finance" => "Finance",
47
+ "legal & compliance" => "Legal",
48
+ "legal" => "Legal",
49
+ "ops & support" => "Ops",
50
+ "ops" => "Ops"
51
+ }.freeze unless defined?(DOMAIN_FROM_HEADING)
52
+
53
+ EMOJI = {
54
+ "Launch-blocking" => "🔴", "Eng" => "🟠", "Design" => "🎨", "Product" => "🧭",
55
+ "Marketing" => "📣", "Finance" => "💰", "Legal" => "⚖️", "Ops" => "🛟"
56
+ }.freeze unless defined?(EMOJI)
57
+ HEADING = {
58
+ "Launch-blocking" => "Launch-blocking (P0)", "Eng" => "Engineering", "Design" => "Design",
59
+ "Product" => "Product", "Marketing" => "Marketing & GTM", "Finance" => "Finance",
60
+ "Legal" => "Legal & Compliance", "Ops" => "Ops & Support"
61
+ }.freeze unless defined?(HEADING)
62
+
63
+ CHECKBOX_TO_STATUS = { " " => "open", "~" => "wip", "x" => "done", "!" => "blocked" }.freeze unless defined?(CHECKBOX_TO_STATUS)
64
+ STATUS_TO_CHECKBOX = { "open" => " ", "wip" => "~", "done" => "x", "blocked" => "!" }.freeze unless defined?(STATUS_TO_CHECKBOX)
65
+ STATUS_ORDER = { "open" => 0, "wip" => 1, "blocked" => 2, "done" => 3 }.freeze unless defined?(STATUS_ORDER)
66
+ PRIORITY_ORDER = { "P0" => 0, "P1" => 1, "P2" => 2 }.freeze unless defined?(PRIORITY_ORDER)
67
+
68
+ # Untriaged tasks (created via the editor flow) carry this sentinel for
69
+ # priority *and* domain until the operating agent assigns real values. They
70
+ # render in the "Needs triage" section at the top of TASKS.md, not in a domain.
71
+ UNTRIAGED = "untriaged".freeze unless defined?(UNTRIAGED)
72
+
73
+ # ---- task-file store -------------------------------------------------------
74
+
75
+ module Store
76
+ module_function
77
+
78
+ def files
79
+ Dir.glob(File.join(TASKS_DIR, "*.md")).reject { |p| File.basename(p).start_with?("_") }.sort
80
+ end
81
+
82
+ def all = files.map { |path| read(path) }
83
+
84
+ # => { meta: {string keys}, body: "..", path: ".." }
85
+ def read(path)
86
+ raw = File.read(path)
87
+ if raw.start_with?("---\n")
88
+ _, fm, body = raw.split(/^---\n/, 3)
89
+ meta = YAML.safe_load(fm, permitted_classes: [ Date ]) || {}
90
+ else
91
+ meta = {}
92
+ body = raw
93
+ end
94
+ { meta: meta.transform_keys(&:to_s).transform_values { |v| v.is_a?(Date) ? v.iso8601 : v },
95
+ body: body.to_s.strip, path: }
96
+ end
97
+
98
+ def write(id:, meta:, body:)
99
+ FileUtils.mkdir_p(TASKS_DIR)
100
+ order = %w[id title priority status domain created updated blocked_on]
101
+ fm = {}
102
+ order.each { |k| fm[k] = meta[k] unless meta[k].nil? || meta[k] == "" }
103
+ meta.each { |k, v| fm[k] = v unless order.include?(k) || v.nil? || v == "" }
104
+ path = File.join(TASKS_DIR, "#{id}.md")
105
+ File.write(path, "#{YAML.dump(fm)}---\n\n#{body.strip}\n")
106
+ path
107
+ end
108
+
109
+ def find(id)
110
+ path = File.join(TASKS_DIR, "#{id}.md")
111
+ raise "No task with id #{id.inspect} (looked for #{path})" unless File.exist?(path)
112
+
113
+ read(path)
114
+ end
115
+
116
+ # Unique slug from a title; suffix -2, -3… on collision.
117
+ def slug_for(title)
118
+ base = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")[0, 60].to_s
119
+ base = "task" if base.empty?
120
+ slug = base
121
+ n = 1
122
+ while File.exist?(File.join(TASKS_DIR, "#{slug}.md"))
123
+ n += 1
124
+ slug = "#{base}-#{n}"
125
+ end
126
+ slug
127
+ end
128
+ end
129
+
130
+ def sort_key(task)
131
+ m = task[:meta]
132
+ [ PRIORITY_ORDER.fetch(m["priority"], 9), STATUS_ORDER.fetch(m["status"], 9), m["id"].to_s ]
133
+ end
134
+
135
+ def untriaged?(task) = task[:meta]["priority"] == UNTRIAGED || task[:meta]["domain"] == UNTRIAGED
136
+
137
+ def domain_heading(domain) = "#{EMOJI[domain]} #{HEADING.fetch(domain, domain)}"
138
+
139
+ PLACEHOLDER_BODY = "_(no description in source)_".freeze unless defined?(PLACEHOLDER_BODY)
140
+
141
+ # Render one task as a "- [x] (P1) Title" line. A short single-line body becomes
142
+ # an inline "— context"; multi-line bodies render indented underneath, verbatim.
143
+ def render_line(task)
144
+ m = task[:meta]
145
+ box = STATUS_TO_CHECKBOX.fetch(m["status"], " ")
146
+ prio = PRIORITY_ORDER.key?(m["priority"]) ? "(#{m['priority']}) " : ""
147
+ out = +"- [#{box}] #{prio}#{m['title']}"
148
+
149
+ body = task[:body].to_s.strip
150
+ body = "" if body == PLACEHOLDER_BODY
151
+ lines = body.lines.map(&:rstrip)
152
+ nonblank = lines.reject { |l| l.strip.empty? }
153
+
154
+ if nonblank.size == 1 && lines.size == 1
155
+ out << " — #{nonblank.first.strip}"
156
+ out << "\n"
157
+ else
158
+ out << "\n"
159
+ lines.each { |l| out << (l.strip.empty? ? "\n" : " #{l}\n") }
160
+ end
161
+ out
162
+ end
163
+
164
+ # ---- tasks:import ----------------------------------------------------------
165
+
166
+ TITLE_MAX = 72 unless defined?(TITLE_MAX) # cap so titles stay scannable one-liners.
167
+
168
+ # Derive a clean, short title from a (possibly wrapped, markdown-laced) lead.
169
+ # The migration's bug was taking the first *wrapped* line verbatim, so titles
170
+ # ended mid-clause ("… create a **Discord"). We instead cut at the first
171
+ # sentence end / em-dash / colon-clause, strip leading markdown, and length-cap
172
+ # at a word boundary so a future import can't reproduce that.
173
+ def clean_title(raw)
174
+ text = raw.to_s.gsub(/\s+/, " ").strip
175
+ text = text.delete("*") # drop markdown bold
176
+ text = text.sub(/\A\[([^\]]+)\]\([^)]*\)/, '\1') # [label](url) -> label
177
+ # First sentence-ish unit: stop at a real sentence period, an em-dash aside,
178
+ # or a "Heading: detail" colon — whichever comes first.
179
+ head = text[/\A.*?(?=(?:\.\s)|(?:\.\z)|\s—\s|: )/, 0] || text
180
+ head = head.strip
181
+ head = text.strip if head.empty?
182
+ head = truncate_at_word(head, TITLE_MAX)
183
+ head.sub(/[\s,:;(—-]+\z/, "").strip # no dangling punctuation
184
+ end
185
+
186
+ # The slice of `lead` left over after `title` was carved off its front — the
187
+ # bullet line's own text that didn't fit in the title, so it isn't dropped.
188
+ # Compares on markdown-stripped text (clean_title strips markdown) and trims
189
+ # the leading separator left dangling (". ", ": ", "— ").
190
+ def clause_after(lead, title)
191
+ plain = lead.to_s.gsub(/\s+/, " ").strip.delete("*")
192
+ plain = plain.sub(/\A\[([^\]]+)\]\([^)]*\)/, '\1')
193
+ rest = plain.delete_prefix(title.to_s.strip)
194
+ rest.sub(/\A[\s.:;,—-]+/, "").strip
195
+ end
196
+
197
+ # Truncate to <= max characters on a word boundary (no trailing partial word).
198
+ def truncate_at_word(str, max)
199
+ return str if str.length <= max
200
+
201
+ cut = str[0, max]
202
+ cut = cut.sub(/\s\S*\z/, "") if str[max] && str[max] != " "
203
+ cut.strip
204
+ end
205
+
206
+
207
+ # Strip the common leading indentation off a block of lines (so the stored
208
+ # body is clean markdown; tasks:index re-indents it consistently). Nesting is
209
+ # preserved relative to the shallowest line.
210
+ def dedent(lines)
211
+ present = lines.reject { |l| l.strip.empty? }
212
+ return lines if present.empty?
213
+
214
+ min = present.map { |l| l[/\A */].length }.min
215
+ lines.map { |l| l.strip.empty? ? "" : l[min..] }
216
+ end
217
+
218
+ # Parse the current TASKS.md into [{checkbox, priority, title, body, domain, section}].
219
+ def parse_index(path)
220
+ section = nil # domain label, :blocked, :shipped, :skip, or nil
221
+ domain = nil
222
+ current = nil
223
+ footer = false # inside a "_…_" section-footer roll-up
224
+ items = []
225
+
226
+ finish = -> { items << current if current; current = nil }
227
+
228
+ File.readlines(path, chomp: true).each do |line|
229
+ # A whole-line italic "_…_" roll-up (e.g. "_Resolved: …_") is a section
230
+ # footer, not task content — skip it and any wrapped continuation.
231
+ if footer
232
+ footer = false if line.rstrip.end_with?("_")
233
+ next
234
+ end
235
+ if line.match?(/\A_\S/)
236
+ finish.call
237
+ footer = !line.rstrip.end_with?("_")
238
+ next
239
+ end
240
+
241
+ # Only top-level "## Heading" switches section/domain. Deeper "### …"
242
+ # sub-headings (e.g. "### Payments & fulfillment") just group tasks within
243
+ # a domain — end the current task but keep the section.
244
+ if line.match?(/\A###+\s+/)
245
+ finish.call
246
+ next
247
+ end
248
+
249
+ if (h = line[/\A##\s+(.+)\z/, 1])
250
+ finish.call
251
+ key = h.downcase.gsub(/[^a-z&\- ]/, "").strip
252
+ if key.include?("blocked on the user")
253
+ section = :blocked
254
+ elsif key.include?("recently shipped")
255
+ section = :shipped
256
+ elsif key.include?("how to use") || key.include?("task format")
257
+ section = :skip
258
+ elsif (d = DOMAIN_FROM_HEADING[key] || DOMAIN_FROM_HEADING.find { |k, _| key.start_with?(k) }&.last)
259
+ section = :domain
260
+ domain = d
261
+ else
262
+ section = nil
263
+ end
264
+ next
265
+ end
266
+
267
+ next if section.nil? || section == :skip
268
+
269
+ # A "---" horizontal rule ends the current task; it isn't task content.
270
+ if line.match?(/\A---+\s*\z/)
271
+ finish.call
272
+ next
273
+ end
274
+
275
+ if (m = line.match(/\A- \[(.)\] (?:\((P\d)\) )?(.*)\z/))
276
+ finish.call
277
+ current = { checkbox: m[1], priority: m[2], title_line: m[3], extra: [],
278
+ section:, domain: }
279
+ elsif section == :shipped && (m = line.match(/\A- (.+)\z/))
280
+ # Recently-shipped entries are checkbox-less one-liners; capture each as a
281
+ # done task so its text isn't lost.
282
+ finish.call
283
+ current = { checkbox: "x", priority: nil, title_line: m[1], extra: [],
284
+ section:, domain: }
285
+ elsif current
286
+ current[:extra] << line
287
+ end
288
+ end
289
+ finish.call
290
+ items
291
+ end
292
+
293
+ desc "Split the current TASKS.md into one file per task under tasks/ (FORCE=1 to overwrite)"
294
+ task :import do
295
+ existing = Store.files
296
+ if existing.any? && ENV["FORCE"] != "1"
297
+ abort "tasks/ already has #{existing.size} task file(s). Re-run with FORCE=1 to overwrite."
298
+ end
299
+ FileUtils.rm_f(existing)
300
+
301
+ created = Date.today.iso8601
302
+ count = 0
303
+
304
+ parse_index(INDEX_FILE).each do |item|
305
+ title_line = item[:title_line].to_s
306
+ leftover = nil
307
+ if item[:section] == :shipped
308
+ # A shipped one-liner is its own summary — keep it whole as the title.
309
+ title = title_line.strip
310
+ rest = nil
311
+ else
312
+ pre, rest = title_line.split(/\s+—\s+/, 2)
313
+ pre = title_line if pre.to_s.strip.empty?
314
+ # Derive a clean title from the bullet's own line: clean_title cuts at the
315
+ # first sentence end / em-dash / colon-clause and strips markdown, so a
316
+ # wrapped run-on can't bleed mid-clause into the title. Any remainder of
317
+ # that line is real content → keep it as the first body line so nothing is
318
+ # lost. The "- context" half (after an em-dash) still becomes body too.
319
+ title = clean_title(pre)
320
+ leftover = clause_after(pre, title)
321
+ end
322
+
323
+ body_lines = []
324
+ body_lines << rest.strip if rest && !rest.strip.empty?
325
+ body_lines << leftover if leftover && !leftover.empty?
326
+ body_lines.concat(dedent(item[:extra]))
327
+ body = body_lines.join("\n").strip
328
+ body = PLACEHOLDER_BODY if body.empty?
329
+
330
+ status =
331
+ case item[:section]
332
+ when :shipped then "done"
333
+ else CHECKBOX_TO_STATUS.fetch(item[:checkbox], "open")
334
+ end
335
+ domain = item[:domain] || (item[:section] == :blocked ? "Launch-blocking" : "Ops")
336
+ priority = item[:priority] || "P1"
337
+
338
+ id = Store.slug_for(title)
339
+ meta = { "id" => id, "title" => title, "priority" => priority, "status" => status,
340
+ "domain" => domain, "created" => created }
341
+ meta["blocked_on"] = "user" if item[:section] == :blocked
342
+ Store.write(id:, meta:, body:)
343
+ count += 1
344
+ end
345
+
346
+ puts "Imported #{count} task(s) into #{TASKS_DIR}/."
347
+ end
348
+
349
+ # ---- tasks:index -----------------------------------------------------------
350
+
351
+ desc "Regenerate TASKS.md from the task files under tasks/"
352
+ task :index do
353
+ tasks = Store.all
354
+ out = +File.read(HEADER_FILE)
355
+ out << "\n" unless out.end_with?("\n\n")
356
+
357
+ # Untriaged tasks (editor flow) surface at the very top so the agent assigns
358
+ # priority + domain. They live in no domain section until triaged.
359
+ needs_triage = tasks.select { |t| untriaged?(t) && t[:meta]["status"] != "done" }
360
+ .sort_by { |t| t[:meta]["created"].to_s }
361
+ unless needs_triage.empty?
362
+ out << "## 🆕 Needs triage\n\n"
363
+ out << "New tasks (created via `rake task`) awaiting priority + domain. Triage with\n" \
364
+ "`rake tasks:triage[id,P1,Eng]`, then they move into a domain section below.\n\n"
365
+ needs_triage.each { |t| out << render_line(t) }
366
+ out << "\n---\n\n"
367
+ end
368
+
369
+ by_domain = tasks.group_by { |t| t[:meta]["domain"] }
370
+ DOMAINS.each do |domain|
371
+ group = (by_domain[domain] || [])
372
+ .reject { |t| t[:meta]["status"] == "done" || t[:meta]["blocked_on"] }
373
+ .sort_by { |t| sort_key(t) }
374
+ next if group.empty?
375
+
376
+ out << "## #{domain_heading(domain)}\n\n"
377
+ group.each { |t| out << render_line(t) }
378
+ out << "\n"
379
+ end
380
+
381
+ out << "---\n\n## 🚧 Blocked on the user\n\n"
382
+ out << "Surfaced here so they're not lost in the sections above. Do the autonomous work; nudge\n" \
383
+ "the user on these.\n\n"
384
+ tasks.select { |t| t[:meta]["blocked_on"] }.sort_by { |t| sort_key(t) }
385
+ .each { |t| out << render_line(t) }
386
+ out << "\n"
387
+
388
+ out << "---\n\n## Recently shipped\n\n"
389
+ out << "Short memory aid only — git history is the full record. Trim as this grows.\n\n"
390
+ tasks.select { |t| t[:meta]["status"] == "done" }
391
+ .sort_by { |t| [ [ t[:meta]["updated"].to_s, t[:meta]["created"].to_s ].max, t[:meta]["id"].to_s ] }
392
+ .reverse
393
+ .each do |t|
394
+ summary = t[:body].to_s.lines.first.to_s.strip
395
+ summary = "" if summary == PLACEHOLDER_BODY
396
+ out << "- #{t[:meta]['title']}#{summary.empty? ? '' : " — #{summary}"}\n"
397
+ end
398
+ out << "\n"
399
+
400
+ File.write(INDEX_FILE, out)
401
+ puts "Wrote #{INDEX_FILE} from #{tasks.size} task file(s)."
402
+ end
403
+
404
+ # ---- git-commit-style editor flow ------------------------------------------
405
+
406
+ # The prefilled buffer the editor opens on: empty title/body, then `#` help
407
+ # lines (stripped on save) explaining the format. Mirrors `git commit`.
408
+ EDITOR_TEMPLATE = (<<~MD).freeze unless defined?(EDITOR_TEMPLATE)
409
+
410
+ # First line above is the TITLE. Leave a blank line, then write the
411
+ # description/body below — like a git commit message.
412
+ #
413
+ # Lines starting with '#' are comments and will be ignored. A file with no
414
+ # title creates nothing. Priority and domain are left UNTRIAGED on purpose;
415
+ # the operating agent triages them later (rake tasks:triage[id,P1,Eng]).
416
+ MD
417
+
418
+ # Split an edited buffer into [title, body]. The first non-empty, non-comment
419
+ # line is the title; everything after it (minus comment lines) is the body.
420
+ # Returns ["", ""] for an empty/all-comment buffer (caller treats as abort).
421
+ def parse_task_input(text)
422
+ lines = text.to_s.lines.map(&:rstrip).reject { |l| l.lstrip.start_with?("#") }
423
+ lines.shift while lines.first&.strip&.empty? # drop leading blanks
424
+ return [ "", "" ] if lines.empty?
425
+
426
+ title = lines.shift.strip
427
+ body = lines.join("\n").strip
428
+ [ title, body ]
429
+ end
430
+
431
+ # Open $VISUAL/$EDITOR (fallback: vi) on a tempfile prefilled with `template`,
432
+ # and return the saved contents. Returns nil when no interactive editor is
433
+ # available so callers can fall back to ENV. Factored out so tests never spawn
434
+ # an editor.
435
+ def edit_in_editor(template)
436
+ editor = ENV["VISUAL"].to_s.strip
437
+ editor = ENV["EDITOR"].to_s.strip if editor.empty?
438
+ editor = "vi" if editor.empty? && $stdin.tty?
439
+ return nil if editor.empty?
440
+
441
+ require "tempfile"
442
+ Tempfile.create([ "task", ".md" ]) do |f|
443
+ f.write(template)
444
+ f.flush
445
+ system("#{editor} #{f.path}") || abort("Editor #{editor.inspect} exited non-zero; aborting.")
446
+ File.read(f.path)
447
+ end
448
+ end
449
+
450
+ # ---- mutators --------------------------------------------------------------
451
+
452
+ desc "Create a new task. No args -> open $EDITOR (title on line 1, body below); " \
453
+ "explicit args set priority/domain directly: rake tasks:new[title,priority,domain]"
454
+ task :new, %i[title priority domain] do |_t, args|
455
+ if args[:title].to_s.strip.empty?
456
+ # No positional title: open an editor (git-commit style). Untriaged by
457
+ # default — the operating agent triages priority/domain later. TITLE/BODY
458
+ # env are the non-interactive fallback (CI, no $EDITOR).
459
+ buffer = edit_in_editor(EDITOR_TEMPLATE)
460
+ title, body = buffer ? parse_task_input(buffer) : [ "", "" ]
461
+ title = (title.presence || ENV["TITLE"]).to_s.strip
462
+ body = (body.presence || ENV["BODY"]).to_s.strip
463
+ abort "No title — nothing created. (Set EDITOR, or pass TITLE=… / rake tasks:new[\"title\"].)" if title.empty?
464
+ priority = UNTRIAGED
465
+ domain = UNTRIAGED
466
+ else
467
+ # Explicit args (scripts/agents): set priority/domain directly, unchanged.
468
+ title = args[:title].to_s.strip
469
+ body = "One line of context / acceptance criteria."
470
+ priority = (args[:priority] || ENV["PRIORITY"] || "P1").to_s
471
+ domain = (args[:domain] || ENV["DOMAIN"] || "Ops").to_s
472
+ end
473
+
474
+ id = Store.slug_for(title)
475
+ Store.write(
476
+ id:, body: body.to_s.strip,
477
+ meta: { "id" => id, "title" => title, "priority" => priority, "status" => "open",
478
+ "domain" => domain, "created" => Date.today.iso8601 }
479
+ )
480
+ Rake::Task["tasks:index"].invoke
481
+ puts "Created tasks/#{id}.md#{" (needs triage)" if priority == UNTRIAGED}"
482
+ end
483
+
484
+ desc "Triage an untriaged task: rake tasks:triage[id,priority,domain]"
485
+ task :triage, %i[id priority domain] do |_t, args|
486
+ id = (args[:id] || ENV["ID"]).to_s.strip
487
+ priority = (args[:priority] || ENV["PRIORITY"]).to_s.strip
488
+ domain = (args[:domain] || ENV["DOMAIN"]).to_s.strip
489
+ abort "Usage: rake tasks:triage[id,P1,Eng]" if id.empty? || priority.empty? || domain.empty?
490
+
491
+ task = Store.find(id)
492
+ task[:meta]["priority"] = priority
493
+ task[:meta]["domain"] = domain
494
+ task[:meta]["updated"] = Date.today.iso8601
495
+ Store.write(id:, meta: task[:meta], body: task[:body])
496
+ Rake::Task["tasks:index"].invoke
497
+ puts "Triaged #{id} -> #{priority} / #{domain}."
498
+ end
499
+
500
+ desc "Claim a task (status: wip): rake tasks:claim[id]"
501
+ task :claim, [ :id ] do |_t, args|
502
+ id = (args[:id] || ENV["ID"]).to_s.strip
503
+ abort "Usage: rake tasks:claim[id]" if id.empty?
504
+ task = Store.find(id)
505
+ task[:meta]["status"] = "wip"
506
+ task[:meta]["updated"] = Date.today.iso8601
507
+ Store.write(id:, meta: task[:meta], body: task[:body])
508
+ Rake::Task["tasks:index"].invoke
509
+ puts "Claimed #{id}."
510
+ end
511
+
512
+ desc "Mark a task done: rake tasks:done[id]"
513
+ task :done, [ :id ] do |_t, args|
514
+ id = (args[:id] || ENV["ID"]).to_s.strip
515
+ abort "Usage: rake tasks:done[id]" if id.empty?
516
+ task = Store.find(id)
517
+ task[:meta]["status"] = "done"
518
+ task[:meta]["updated"] = Date.today.iso8601
519
+ task[:meta].delete("blocked_on")
520
+ Store.write(id:, meta: task[:meta], body: task[:body])
521
+ Rake::Task["tasks:index"].invoke
522
+ puts "Marked #{id} done."
523
+ end
524
+ end
525
+
526
+ # Top-level convenience alias: `rake task` == `rake tasks:new` with no args
527
+ # (opens the git-commit-style editor). Lives outside the namespace so it's a
528
+ # bare verb you can muscle-memory.
529
+ desc "Create a backlog task in your $EDITOR (git-commit style): rake task"
530
+ task :task do
531
+ Rake::Task["tasks:new"].invoke
532
+ end
@@ -0,0 +1,37 @@
1
+ # CafeCar — Tasks
2
+
3
+ > **GENERATED FILE — do not hand-edit.** Regenerated by `rake tasks:index` from the
4
+ > per-task files under `tasks/`. To change the backlog, edit a file in `tasks/` (or use
5
+ > `rake tasks:new|claim|done`) and re-run the index.
6
+
7
+ The single backlog for growing CafeCar's OSS reach and quality, across **every** domain:
8
+ engineering, design, product, marketing/GTM, finance, legal, and ops/support. You (the
9
+ agent) are the conductor of this gem — see `AGENTS.md`. If a thing needs doing for CafeCar
10
+ to gain adoption and trust, it lives here.
11
+
12
+ This file is the **what's left to do**. `README.md` is the **how things work / current
13
+ state** (features, usage, architecture). Don't duplicate: put actionable work here,
14
+ reference facts there, and link between them.
15
+
16
+ ---
17
+
18
+ ## How to use this file
19
+
20
+ - **One file per task.** Each task is a markdown file with YAML frontmatter under `tasks/` —
21
+ the source of truth and one merge unit, so parallel agents creating/finishing different
22
+ tasks never race on a push. This `TASKS.md` is a generated index; **don't hand-edit it.**
23
+ - **Pick up work** top-down: P0 (launch-blocking) first, then P1, then P2.
24
+ - **Add a task**: `rake task` opens your `$EDITOR` (git-commit style — first line is the
25
+ title, the rest is the body); it lands **untriaged** in "🆕 Needs triage" below for the
26
+ agent to prioritize. Scripts/agents can pass args directly: `rake tasks:new["Title",P1,Eng]`.
27
+ Triage with `rake tasks:triage[id,P1,Eng]`.
28
+ - **Claim before you start**: `rake tasks:claim[id]` (sets `status: wip`). Finish with
29
+ `rake tasks:done[id]`.
30
+ - **Regenerate** after editing any task: `rake tasks:index`.
31
+ - **Blocked on the user?** Set `blocked_on: user` in the task — it rolls up into
32
+ `## Blocked on the user` below.
33
+
34
+ Status: `[ ]` open · `[~]` wip · `[x]` done · `[!]` blocked
35
+ Priority: `P0` launch-blocking · `P1` important, soon · `P2` nice-to-have / later
36
+
37
+ ---