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.
- checksums.yaml +4 -4
- data/README.md +155 -40
- data/Rakefile +9 -1
- data/app/assets/fonts/Lexend.css +7 -0
- data/app/assets/fonts/Lexend.ttf +0 -0
- data/app/assets/stylesheets/cafe_car/themes/defaults.css +58 -59
- data/app/assets/stylesheets/cafe_car/tooltips.css +20 -0
- data/app/assets/stylesheets/cafe_car/utility.css +1 -2
- data/app/assets/stylesheets/cafe_car.css +17 -6
- data/app/assets/stylesheets/ui/Alert.css +2 -1
- data/app/assets/stylesheets/ui/Button.css +6 -6
- data/app/assets/stylesheets/ui/Card.css +7 -3
- data/app/assets/stylesheets/ui/Chat.css +33 -0
- data/app/assets/stylesheets/ui/Close.css +11 -0
- data/app/assets/stylesheets/ui/Grid.css +2 -0
- data/app/assets/stylesheets/ui/Icon.css +3 -3
- data/app/assets/stylesheets/ui/Layout.css +20 -13
- data/app/assets/stylesheets/ui/Modal.css +5 -12
- data/app/assets/stylesheets/ui/Navigation.css +13 -5
- data/app/assets/stylesheets/ui/Page.css +42 -3
- data/app/assets/stylesheets/ui/Table.css +27 -56
- data/app/assets/stylesheets/ui/components.css +2 -0
- data/app/controllers/cafe_car/examples_controller.rb +1 -1
- data/app/controllers/cafe_car/sessions_controller.rb +30 -0
- data/app/controllers/concerns/cafe_car/authentication.rb +61 -0
- data/app/javascript/cafe_car.js +16 -11
- data/app/models/cafe_car/session.rb +18 -0
- data/app/policies/cafe_car/session_policy.rb +19 -0
- data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +5 -4
- data/app/presenters/cafe_car/code_presenter.rb +18 -0
- data/app/presenters/cafe_car/date_presenter.rb +1 -0
- data/app/presenters/cafe_car/date_time_presenter.rb +2 -2
- data/app/presenters/cafe_car/enumerable_presenter.rb +1 -1
- data/app/presenters/cafe_car/hash_presenter.rb +3 -8
- data/app/presenters/cafe_car/presenter.rb +22 -12
- data/app/presenters/cafe_car/string_presenter.rb +2 -2
- data/app/ui/cafe_car/ui/button.rb +2 -1
- data/app/ui/cafe_car/ui/card.rb +18 -0
- data/app/ui/cafe_car/ui/grid.rb +30 -0
- data/app/ui/cafe_car/ui/layout.rb +7 -0
- data/app/ui/cafe_car/ui/page.rb +5 -1
- data/app/views/application/_body.html.haml +2 -1
- data/app/views/application/_controls.html.haml +1 -0
- data/app/views/application/_debug.html.haml +9 -2
- data/app/views/application/_errors.html.haml +4 -8
- data/app/views/application/_grid.html.haml +1 -1
- data/app/views/application/_grid_item.html.haml +1 -1
- data/app/views/application/_head.html.haml +1 -0
- data/app/views/application/_index.html.haml +6 -2
- data/app/views/application/_index_actions.html.haml +3 -3
- data/app/views/application/_navigation.html.haml +7 -0
- data/app/views/application/_navigation_links.html.haml +1 -1
- data/app/views/application/_notes.html.haml +1 -0
- data/app/views/application/_popup.html.haml +7 -0
- data/app/views/cafe_car/application/edit.html.haml +1 -1
- data/app/views/cafe_car/application/edit.turbo_stream.haml +3 -5
- data/app/views/cafe_car/application/index.html.haml +3 -0
- data/app/views/cafe_car/application/new.turbo_stream.haml +5 -6
- data/app/views/cafe_car/application/show.html.haml +2 -2
- data/app/views/cafe_car/examples/ui/_chat.html.haml +3 -0
- data/app/views/cafe_car/examples/ui/_info_circle.html.haml +1 -1
- data/app/views/cafe_car/examples/ui/_modal.html.haml +1 -1
- data/app/views/passwords_mailer/reset.html.haml +5 -0
- data/app/views/passwords_mailer/reset.text.erb +4 -0
- data/app/views/ui/_card.html.haml +6 -11
- data/app/views/ui/_field.html.haml +1 -7
- data/app/views/ui/_modal_close.html.haml +1 -2
- data/app/views/ui/_page.html.haml +6 -12
- data/config/brakeman.ignore +3 -3
- data/config/locales/en.yml +10 -2
- data/config/routes.rb +5 -1
- data/db/migrate/20251005220017_create_slugs.rb +2 -2
- data/lib/cafe_car/active_record.rb +1 -1
- data/lib/cafe_car/application_responder.rb +6 -0
- data/lib/cafe_car/attributes.rb +1 -1
- data/lib/cafe_car/auto_resolver.rb +1 -1
- data/lib/cafe_car/component.rb +102 -39
- data/lib/cafe_car/context.rb +5 -4
- data/lib/cafe_car/controller/filtering.rb +9 -1
- data/lib/cafe_car/controller.rb +52 -13
- data/lib/cafe_car/core_ext/array.rb +13 -0
- data/lib/cafe_car/core_ext/hash.rb +15 -0
- data/lib/cafe_car/core_ext/module.rb +15 -0
- data/lib/cafe_car/core_ext.rb +0 -2
- data/lib/cafe_car/current.rb +4 -1
- data/lib/cafe_car/engine.rb +9 -2
- data/lib/cafe_car/field_builder.rb +1 -1
- data/lib/cafe_car/field_info.rb +14 -12
- data/lib/cafe_car/fields.rb +7 -0
- data/lib/cafe_car/filter/field_info.rb +1 -1
- data/lib/cafe_car/filter/form_builder.rb +2 -2
- data/lib/cafe_car/filter_builder.rb +1 -1
- data/lib/cafe_car/form_builder.rb +1 -1
- data/lib/cafe_car/generators.rb +1 -1
- data/lib/cafe_car/helpers.rb +37 -10
- data/lib/cafe_car/href_builder.rb +35 -9
- data/lib/cafe_car/input_builder.rb +1 -1
- data/lib/cafe_car/link_builder.rb +14 -11
- data/lib/cafe_car/model.rb +2 -2
- data/lib/cafe_car/navigation.rb +10 -10
- data/lib/cafe_car/option_helpers.rb +11 -5
- data/lib/cafe_car/param_parser.rb +10 -6
- data/lib/cafe_car/policy.rb +2 -2
- data/lib/cafe_car/query_builder.rb +3 -3
- data/lib/cafe_car/resolver.rb +5 -1
- data/lib/cafe_car/routing.rb +1 -1
- data/lib/cafe_car/table/builder.rb +3 -2
- data/lib/cafe_car/table/head_builder.rb +2 -2
- data/lib/cafe_car/table/label_builder.rb +1 -1
- data/lib/cafe_car/table/row_builder.rb +5 -7
- data/lib/cafe_car/table_builder.rb +3 -3
- data/lib/cafe_car/ui.rb +2 -0
- data/lib/cafe_car/version.rb +1 -1
- data/lib/cafe_car/visitors.rb +2 -2
- data/lib/cafe_car.rb +25 -0
- data/lib/generators/cafe_car/controller/templates/controller.rb.tt +1 -1
- data/lib/generators/cafe_car/install/install_generator.rb +0 -1
- data/lib/generators/cafe_car/sessions/USAGE +17 -0
- data/lib/generators/cafe_car/sessions/sessions_generator.rb +29 -0
- data/lib/generators/cafe_car/sessions/templates/create_sessions.rb.tt +12 -0
- data/lib/tasks/holdco_tasks.rake +532 -0
- data/lib/tasks/templates/tasks_header.md +37 -0
- metadata +76 -48
- data/app/models/cafe_car/slug.rb +0 -3
- data/app/views/ui/_grid.html.haml +0 -17
- 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
|
+
---
|