bacon-tracker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +430 -0
- data/bin/tracker-dashboard +47 -0
- data/bin/tracker-init +267 -0
- data/lib/bacon_tracker/assets/app.js +1258 -0
- data/lib/bacon_tracker/assets/decisions.js +382 -0
- data/lib/bacon_tracker/assets/docs.js +456 -0
- data/lib/bacon_tracker/assets/logic.js +85 -0
- data/lib/bacon_tracker/assets/theme.js +33 -0
- data/lib/bacon_tracker/commands/tracker.md +269 -0
- data/lib/bacon_tracker/dashboard.rb +168 -0
- data/lib/bacon_tracker/launcher.rb +64 -0
- data/lib/bacon_tracker/server.rb +476 -0
- data/lib/bacon_tracker/tasks.rb +465 -0
- data/lib/bacon_tracker/version.rb +3 -0
- data/lib/bacon_tracker/views/_board_css.erb +122 -0
- data/lib/bacon_tracker/views/_detail_css.erb +109 -0
- data/lib/bacon_tracker/views/_header_css.erb +82 -0
- data/lib/bacon_tracker/views/_markdown_css.erb +81 -0
- data/lib/bacon_tracker/views/_root_css.erb +41 -0
- data/lib/bacon_tracker/views/_theme_boot.erb +1 -0
- data/lib/bacon_tracker/views/dashboard.erb +281 -0
- data/lib/bacon_tracker/views/decisions.erb +211 -0
- data/lib/bacon_tracker/views/docs.erb +234 -0
- data/lib/bacon_tracker/views/index.erb +1065 -0
- data/lib/bacon_tracker.rb +1993 -0
- metadata +213 -0
data/bin/tracker-init
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "bacon_tracker"
|
|
7
|
+
|
|
8
|
+
# ── CLI options ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
options = {}
|
|
11
|
+
OptionParser.new do |opts|
|
|
12
|
+
opts.banner = <<~BANNER
|
|
13
|
+
Usage: tracker-init [options]
|
|
14
|
+
|
|
15
|
+
Creates tracker/ and docs/decisions/ inside a project directory and
|
|
16
|
+
registers the project in your dashboard.md.
|
|
17
|
+
|
|
18
|
+
BANNER
|
|
19
|
+
opts.on("--path PATH", "Project directory (tracker/ and docs/decisions/ are created inside)") { |v| options[:path] = v }
|
|
20
|
+
opts.on("--namespace NS", "Story ID prefix - 2-8 letters/digits starting with a letter, e.g. APP") { |v| options[:namespace] = v }
|
|
21
|
+
opts.on("--title TITLE", "Project title (default: from the directory name)") { |v| options[:title] = v }
|
|
22
|
+
opts.on("--dashboard PATH", "Path to dashboard.md (default: ./dashboard.md)") { |v| options[:dashboard] = v }
|
|
23
|
+
opts.on("--command", "Also install the /tracker Claude Code command") { options[:command] = true }
|
|
24
|
+
opts.on("-y", "--yes", "Non-interactive: no prompts, defaults for anything not given") { options[:yes] = true }
|
|
25
|
+
opts.on("-h", "--help", "Show this help") { puts opts; exit }
|
|
26
|
+
end.parse!
|
|
27
|
+
OPTIONS_YES = options[:yes]
|
|
28
|
+
|
|
29
|
+
# ── Interactive prompts ───────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def prompt(label, default: nil, required: false)
|
|
32
|
+
# --yes means no prompts at all: take the default, or fail with the flag to
|
|
33
|
+
# pass - never block a script on stdin (BT-179).
|
|
34
|
+
if OPTIONS_YES
|
|
35
|
+
return default if default
|
|
36
|
+
|
|
37
|
+
warn " Error: #{label} is required - pass it as a flag with --yes."
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if default
|
|
42
|
+
print " #{label} [#{default}]: "
|
|
43
|
+
else
|
|
44
|
+
print " #{label}: "
|
|
45
|
+
end
|
|
46
|
+
$stdout.flush
|
|
47
|
+
val = $stdin.gets&.chomp
|
|
48
|
+
val = nil if val&.empty?
|
|
49
|
+
result = val || default
|
|
50
|
+
if required && (result.nil? || result.empty?)
|
|
51
|
+
warn " Error: #{label} is required."
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
puts
|
|
58
|
+
puts " tracker-init - initialize a new tracker project"
|
|
59
|
+
puts " " + ("─" * 44)
|
|
60
|
+
puts
|
|
61
|
+
|
|
62
|
+
path = options[:path] ||
|
|
63
|
+
prompt("Project directory (tracker/ goes here)", required: true)
|
|
64
|
+
path = File.expand_path(path)
|
|
65
|
+
|
|
66
|
+
namespace = options[:namespace] ||
|
|
67
|
+
prompt("Namespace (2-8 letters/digits, e.g. APP)", required: true)
|
|
68
|
+
namespace = namespace.strip.upcase
|
|
69
|
+
# Validate rather than rewrite: silently turning B2B into BB or bacon-tracker
|
|
70
|
+
# into BACO registered a namespace the user never chose (BT-179). Digits are
|
|
71
|
+
# fine - the core sorts on the trailing number (BT-104).
|
|
72
|
+
unless namespace.match?(/\A[A-Z][A-Z0-9]{1,7}\z/)
|
|
73
|
+
warn " Error: namespace must be 2-8 characters, a letter followed by letters or digits (got #{namespace.inspect})."
|
|
74
|
+
exit 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
default_title = File.basename(path).gsub(/[-_]/, ' ').split.map(&:capitalize).join(' ')
|
|
78
|
+
title = options[:title] ||
|
|
79
|
+
prompt("Project title", default: default_title, required: true)
|
|
80
|
+
|
|
81
|
+
default_dashboard = File.join(Dir.pwd, 'dashboard.md')
|
|
82
|
+
dashboard_path = options[:dashboard] ||
|
|
83
|
+
prompt("dashboard.md location", default: default_dashboard)
|
|
84
|
+
dashboard_path = File.expand_path(dashboard_path)
|
|
85
|
+
|
|
86
|
+
tracker_root = File.join(path, 'tracker')
|
|
87
|
+
dashboard_dir = File.dirname(dashboard_path)
|
|
88
|
+
|
|
89
|
+
# ── Collision check (before creating anything) ────────────────────────────────
|
|
90
|
+
# Refuse a namespace/path already registered in dashboard.md up front, so a
|
|
91
|
+
# collision can't leave an orphaned, unregistered tracker whose summary then
|
|
92
|
+
# tells the user to run NS=<ns> commands that target a different project (BT-109).
|
|
93
|
+
if File.exist?(dashboard_path)
|
|
94
|
+
existing = File.read(dashboard_path, encoding: 'utf-8')
|
|
95
|
+
if existing.match?(/^namespace: #{Regexp.escape(namespace)}$/)
|
|
96
|
+
warn " Error: namespace #{namespace} is already registered in #{dashboard_path}."
|
|
97
|
+
exit 1
|
|
98
|
+
elsif existing.match?(/^path: (#{Regexp.escape(path)}|#{Regexp.escape(tracker_root)})$/)
|
|
99
|
+
warn " Error: #{path} is already registered in #{dashboard_path}."
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ── Confirm ───────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
puts
|
|
107
|
+
puts " Ready to create:"
|
|
108
|
+
puts " Tracker: #{tracker_root}"
|
|
109
|
+
puts " Namespace: #{namespace}"
|
|
110
|
+
puts " Title: #{title}"
|
|
111
|
+
puts " Dashboard: #{dashboard_path}"
|
|
112
|
+
puts " /tracker: #{options[:command] ? 'install Claude command' : 'skipped (pass --command to install)'}"
|
|
113
|
+
puts
|
|
114
|
+
|
|
115
|
+
unless options[:yes]
|
|
116
|
+
print " Proceed? [Y/n]: "
|
|
117
|
+
$stdout.flush
|
|
118
|
+
answer = $stdin.gets&.chomp.to_s.strip.downcase
|
|
119
|
+
if answer.start_with?('n', 'q') # 'n', 'no', 'q', 'quit' all cancel
|
|
120
|
+
puts " Cancelled."
|
|
121
|
+
exit 0
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ── Directory structure ───────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
puts
|
|
128
|
+
puts " Creating tracker structure..."
|
|
129
|
+
|
|
130
|
+
BaconTracker::STORY_DIRS.each do |type_dir, meta|
|
|
131
|
+
ext = meta[:ext]
|
|
132
|
+
|
|
133
|
+
BaconTracker::STAGES.each do |stage|
|
|
134
|
+
dir = File.join(tracker_root, type_dir, stage)
|
|
135
|
+
FileUtils.mkdir_p(dir)
|
|
136
|
+
gitkeep = File.join(dir, '.gitkeep')
|
|
137
|
+
File.write(gitkeep, '') unless File.exist?(gitkeep)
|
|
138
|
+
puts " tracker/#{type_dir}/#{stage}/"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
template_path = File.join(tracker_root, type_dir, "_template#{ext}")
|
|
142
|
+
unless File.exist?(template_path)
|
|
143
|
+
File.write(template_path, BaconTracker::Templates.for(ext, meta[:type]))
|
|
144
|
+
puts " tracker/#{type_dir}/_template#{ext}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Every mutation takes a flock on <root>/.lock. It is machine-local state, so
|
|
149
|
+
# each root ignores its own rather than relying on the host repo's .gitignore.
|
|
150
|
+
def ignore_lock(root)
|
|
151
|
+
ignore = File.join(root, '.gitignore')
|
|
152
|
+
return if File.exist?(ignore)
|
|
153
|
+
|
|
154
|
+
File.write(ignore, ".lock\n")
|
|
155
|
+
puts " #{File.basename(root)}/.gitignore"
|
|
156
|
+
end
|
|
157
|
+
ignore_lock(tracker_root)
|
|
158
|
+
|
|
159
|
+
# .next-id
|
|
160
|
+
next_id_path = File.join(tracker_root, '.next-id')
|
|
161
|
+
unless File.exist?(next_id_path)
|
|
162
|
+
File.write(next_id_path, '1')
|
|
163
|
+
puts " tracker/.next-id"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# backlog.md
|
|
167
|
+
backlog_path = File.join(tracker_root, 'backlog.md')
|
|
168
|
+
unless File.exist?(backlog_path)
|
|
169
|
+
File.write(backlog_path, "# #{title} Backlog\n\n")
|
|
170
|
+
puts " tracker/backlog.md"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ── Decisions (BT-ADR-0014) ───────────────────────────────────────────────────
|
|
174
|
+
# Scaffolded beside the tracker so the contract applies to something from day
|
|
175
|
+
# one. `.next-id` is written eagerly rather than on first use because it is what
|
|
176
|
+
# marks a tracked subtree (BT-ADR-0016); a lazily-created marker is unreliable.
|
|
177
|
+
decisions_root = File.join(path, 'docs', 'decisions')
|
|
178
|
+
unless Dir.exist?(decisions_root)
|
|
179
|
+
puts " decisions:"
|
|
180
|
+
BaconTracker::STATUSES.each do |status|
|
|
181
|
+
FileUtils.mkdir_p(File.join(decisions_root, status))
|
|
182
|
+
FileUtils.touch(File.join(decisions_root, status, '.gitkeep'))
|
|
183
|
+
puts " docs/decisions/#{status}/"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
File.write(File.join(decisions_root, '_template.md'), BaconTracker::Templates::DECISION)
|
|
187
|
+
File.write(File.join(decisions_root, '.next-id'), '1')
|
|
188
|
+
File.write(File.join(decisions_root, 'proposed.md'), "# Decisions to make, in order\n\n")
|
|
189
|
+
puts " docs/decisions/_template.md"
|
|
190
|
+
puts " docs/decisions/.next-id"
|
|
191
|
+
puts " docs/decisions/proposed.md"
|
|
192
|
+
ignore_lock(decisions_root)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ── Register in dashboard.md ──────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
puts
|
|
198
|
+
# `path:` is the project directory; tracker/ and docs/ are found inside it
|
|
199
|
+
# (BT-ADR-0016). Pointing it at tracker/ itself is the older form, still read.
|
|
200
|
+
entry = "\n## #{title}\npath: #{path}\nnamespace: #{namespace}\n"
|
|
201
|
+
|
|
202
|
+
# A namespace/path collision already aborted above, so registration is a plain
|
|
203
|
+
# append (or first-time create).
|
|
204
|
+
if File.exist?(dashboard_path)
|
|
205
|
+
File.open(dashboard_path, 'a') { |f| f.write(entry) }
|
|
206
|
+
puts " Registered in #{dashboard_path}"
|
|
207
|
+
else
|
|
208
|
+
FileUtils.mkdir_p(dashboard_dir)
|
|
209
|
+
File.write(dashboard_path, "# Bacon Dashboard\n#{entry}")
|
|
210
|
+
puts " Created #{dashboard_path}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ── Rakefile in dashboard home ────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
rakefile_path = File.join(dashboard_dir, 'Rakefile')
|
|
216
|
+
unless File.exist?(rakefile_path)
|
|
217
|
+
File.write(rakefile_path, <<~RUBY)
|
|
218
|
+
# Bacon Tracker - central Rakefile
|
|
219
|
+
# Run from this directory with NS=<namespace> rake story:<command>
|
|
220
|
+
#
|
|
221
|
+
# Examples:
|
|
222
|
+
# Quote the task: zsh (the macOS default) treats [ ] as a glob.
|
|
223
|
+
#
|
|
224
|
+
# NS=#{namespace} rake "story:feature[My feature title]"
|
|
225
|
+
# NS=#{namespace} rake "story:bug[Broken thing]"
|
|
226
|
+
# NS=#{namespace} rake "story:chore[Update deps]"
|
|
227
|
+
# NS=#{namespace} rake "story:commit[#{namespace}-001]"
|
|
228
|
+
# NS=#{namespace} rake "story:start[#{namespace}-001]"
|
|
229
|
+
# NS=#{namespace} rake "story:done[#{namespace}-001]"
|
|
230
|
+
# NS=#{namespace} rake story:next
|
|
231
|
+
# NS=#{namespace} rake story:lint
|
|
232
|
+
# NS=#{namespace} rake "decision:new[Use Postgres]"
|
|
233
|
+
|
|
234
|
+
require 'bacon_tracker'
|
|
235
|
+
require 'bacon_tracker/tasks'
|
|
236
|
+
|
|
237
|
+
BaconTracker::Tasks.install_for_dashboard(File.join(__dir__, '#{File.basename(dashboard_path)}'))
|
|
238
|
+
RUBY
|
|
239
|
+
puts " Created #{rakefile_path}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# ── /tracker Claude command (opt-in) ──────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
if options[:command]
|
|
245
|
+
command_source = File.join(BaconTracker::COMMANDS_DIR, 'tracker.md')
|
|
246
|
+
command_target = File.join(dashboard_dir, '.claude', 'commands', 'tracker.md')
|
|
247
|
+
if File.exist?(command_target)
|
|
248
|
+
puts " #{command_target} already exists - skipping."
|
|
249
|
+
else
|
|
250
|
+
FileUtils.mkdir_p(File.dirname(command_target))
|
|
251
|
+
FileUtils.cp(command_source, command_target)
|
|
252
|
+
puts " Installed /tracker command at #{command_target}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# ── Summary ───────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
puts
|
|
259
|
+
puts " Done! Next steps:"
|
|
260
|
+
puts
|
|
261
|
+
puts " cd #{dashboard_dir}"
|
|
262
|
+
puts " NS=#{namespace} rake \"story:feature[My first feature]\""
|
|
263
|
+
puts
|
|
264
|
+
puts " Or start the dashboard server:"
|
|
265
|
+
puts
|
|
266
|
+
puts " tracker-dashboard --dashboard #{dashboard_path}"
|
|
267
|
+
puts
|