@zalom/plastic 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (76) hide show
  1. package/PLASTIC.md +128 -473
  2. package/README.md +90 -58
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/bin/plastic.js +57 -0
  6. package/bin/test +28 -0
  7. package/deprecations.yml +7 -6
  8. package/hooks/auto-arm +5 -0
  9. package/hooks/bash-gate +3 -0
  10. package/hooks/check-update +12 -8
  11. package/hooks/code-gate +10 -0
  12. package/hooks/hooks.json +25 -4
  13. package/hooks/statusline +50 -10
  14. package/package.json +2 -2
  15. package/scripts/dashboard.rb +480 -0
  16. package/scripts/doctor.rb +973 -0
  17. package/scripts/hook-auto-arm +52 -0
  18. package/scripts/hook-bash-gate +53 -0
  19. package/scripts/hook-code-gate +39 -0
  20. package/scripts/hook-continue +15 -114
  21. package/scripts/hook-gate-check +19 -4
  22. package/scripts/hook-session-start +76 -31
  23. package/scripts/install.rb +91 -480
  24. package/scripts/lib/bridge.rb +255 -0
  25. package/scripts/lib/installer_core.rb +760 -0
  26. package/scripts/migrate-to-global +1 -1
  27. package/scripts/select-update-target +93 -0
  28. package/scripts/uninstall.rb +53 -0
  29. package/scripts/update.rb +142 -0
  30. package/scripts/versions.rb +141 -0
  31. package/skills/_active-intent-gate.md +26 -0
  32. package/skills/auto/SKILL.md +62 -9
  33. package/skills/auto/evals/evals.json +92 -0
  34. package/skills/auto/references/agent-architecture.md +60 -0
  35. package/skills/brainstorming/SKILL.md +143 -0
  36. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  37. package/skills/continuing/SKILL.md +102 -77
  38. package/skills/continuing/evals/evals.json +136 -0
  39. package/skills/continuing/references/context-management.md +32 -0
  40. package/skills/creating-intent/SKILL.md +16 -1
  41. package/skills/creating-intent/references/lifecycle.md +74 -0
  42. package/skills/creating-intent/references/wikilinks.md +8 -0
  43. package/skills/creating-project/SKILL.md +8 -4
  44. package/skills/creating-project/references/hubs-projects.md +55 -0
  45. package/skills/dashboard/SKILL.md +92 -0
  46. package/skills/doctor/SKILL.md +116 -0
  47. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  48. package/skills/doctor/report.md +96 -0
  49. package/skills/evaluating-skills/SKILL.md +140 -0
  50. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  51. package/skills/evaluating-skills/evals/evals.json +75 -0
  52. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  53. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  54. package/skills/executing-plan/SKILL.md +3 -3
  55. package/skills/install/SKILL.md +56 -8
  56. package/skills/intent-curator/SKILL.md +3 -3
  57. package/skills/linking-intents/SKILL.md +5 -1
  58. package/skills/linking-intents/references/zettelkasten.md +33 -0
  59. package/skills/managing-index/SKILL.md +5 -1
  60. package/skills/releasing/SKILL.md +119 -18
  61. package/skills/releasing/references/deprecations.md +44 -0
  62. package/skills/research/SKILL.md +114 -0
  63. package/skills/savepoint/SKILL.md +46 -37
  64. package/skills/savepoint/references/context-management.md +32 -0
  65. package/skills/uninstall/SKILL.md +39 -28
  66. package/skills/update/SKILL.md +41 -36
  67. package/skills/versions/SKILL.md +65 -0
  68. package/skills/writing-instructions/SKILL.md +159 -0
  69. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  70. package/skills/writing-plans/SKILL.md +183 -0
  71. package/templates/agents.md +16 -0
  72. package/templates/outcome.md +13 -0
  73. package/templates/project.yml +5 -0
  74. package/templates/savepoint.md +14 -13
  75. package/templates/spec.md +25 -0
  76. package/bin/install.js +0 -29
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Plastic dashboard — deterministic, template-driven work cockpit.
6
+ #
7
+ # Parses the intent store(s), classifies every actionable intent on a Value x Effort
8
+ # matrix (Eisenhower-style), and renders a uniform view that tells a human AND an agent
9
+ # what to work on next and how to conduct it. The LLM is never in the rendering path:
10
+ # same store state -> byte-identical output.
11
+ #
12
+ # Usage:
13
+ # ruby dashboard.rb [continue|project <slug>|all] [--json]
14
+ # Default mode: continue.
15
+ #
16
+ # Env overrides (for deterministic tests):
17
+ # PLASTIC_HOME — root of the Plastic store (default ~/.plastic)
18
+ # DASHBOARD_TODAY — YYYY-MM-DD used for the header + staleness math
19
+ #
20
+ # Read-only. Never modifies files.
21
+
22
+ require "json"
23
+ require "yaml"
24
+ require "date"
25
+
26
+ PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
27
+
28
+ def today
29
+ s = ENV["DASHBOARD_TODAY"]
30
+ s && !s.empty? ? Date.parse(s) : Date.today
31
+ end
32
+
33
+ STALE_DAYS = 14
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Parsing — reuses the conventions in scripts/doctor.rb
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def parse_frontmatter(path)
40
+ return nil unless File.exist?(path)
41
+ content = File.read(path)
42
+ return nil unless content.start_with?("---")
43
+ parts = content.split("---", 3)
44
+ return nil if parts.length < 3
45
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ # Returns the set of intent ids listed under a given INDEX.md section.
51
+ def index_section_ids(index_path, header)
52
+ return [] unless File.exist?(index_path)
53
+ body = File.read(index_path)
54
+ seg = body[/^#{Regexp.escape(header)}\s*\n(.*?)(?=^## |\z)/m, 1]
55
+ return [] unless seg
56
+ seg.scan(/^- \[([^\]\s]+)/).flatten
57
+ end
58
+
59
+ # Map of intent id -> completion date string, parsed from the "## Completed" section
60
+ # (lines like "- [12 — ...](...) — 2026-06-10"). Deterministic, content-derived.
61
+ def completion_dates(index_path)
62
+ return {} unless File.exist?(index_path)
63
+ body = File.read(index_path)
64
+ seg = body[/^## Completed\s*\n(.*?)(?=^## |\z)/m, 1] || ""
65
+ seg.scan(/^- \[([^\]\s]+).*?—\s*(\d{4}-\d{2}-\d{2})\s*$/).to_h
66
+ end
67
+
68
+ # All stores: global + every registered project. -> [{scope, store, index}]
69
+ def stores
70
+ list = []
71
+ global = File.join(PLASTIC_HOME, "store")
72
+ list << { scope: "global", store: global, index: File.join(PLASTIC_HOME, "INDEX.md") } if File.directory?(global)
73
+ projects_root = File.join(PLASTIC_HOME, "projects")
74
+ if File.directory?(projects_root)
75
+ Dir.children(projects_root).sort.each do |proj|
76
+ store = File.join(projects_root, proj, "store")
77
+ next unless File.directory?(store)
78
+ list << { scope: "project:#{proj}", store: store, index: File.join(projects_root, proj, "INDEX.md") }
79
+ end
80
+ end
81
+ list
82
+ end
83
+
84
+ def intent_dirs(store)
85
+ Dir.children(store).reject { |e| e.start_with?(".") }
86
+ .select { |e| File.directory?(File.join(store, e)) }
87
+ .sort
88
+ end
89
+
90
+ # Parse one intent directory into a raw record.
91
+ def parse_intent(store_info, dir_name, status_index)
92
+ dir = File.join(store_info[:store], dir_name)
93
+ md = File.join(dir, "#{dir_name}.md")
94
+ fm = parse_frontmatter(md)
95
+ return nil unless fm && fm["id"]
96
+
97
+ id = fm["id"].to_s
98
+ has = ->(f) { File.exist?(File.join(dir, f)) }
99
+ body = File.exist?(md) ? File.read(md) : ""
100
+
101
+ status =
102
+ if has.("outcome.md") then "completed"
103
+ elsif status_index[:active].include?(id) then "active"
104
+ elsif status_index[:abandoned].include?(id) then "abandoned"
105
+ elsif status_index[:completed].include?(id) then "completed"
106
+ else "future"
107
+ end
108
+
109
+ {
110
+ id: id,
111
+ scope: store_info[:scope],
112
+ intent: (fm["intent"] || "").to_s.strip,
113
+ author: (fm["author"] || "").to_s,
114
+ tags: fm["tags"] || [],
115
+ sources: (fm["sources"] || []).map(&:to_s),
116
+ chain: (fm["chain"] || []).map(&:to_s),
117
+ created: (fm["created"].to_s rescue ""),
118
+ value_field: fm["value"] && fm["value"].to_s,
119
+ status: status,
120
+ spec: has.("spec.md"),
121
+ plan: has.("plan.md"),
122
+ checklist: has.("checklist.md"),
123
+ outcome: has.("outcome.md"),
124
+ savepoint: has.("savepoint.md"),
125
+ checklist_partial: has.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
126
+ body_has_context: body.include?("## Context"),
127
+ }
128
+ end
129
+
130
+ def checklist_partially_done?(path)
131
+ txt = File.read(path)
132
+ checked = txt.scan(/^\s*- \[x\]/i).size
133
+ total = txt.scan(/^\s*- \[[ x]\]/i).size
134
+ checked.positive? && checked < total
135
+ rescue StandardError
136
+ false
137
+ end
138
+
139
+ # Load every intent across all stores, with a completion-id set for unblock detection.
140
+ def load_all
141
+ all = []
142
+ done_ids = {}
143
+ stores.each do |si|
144
+ idx = {
145
+ active: index_section_ids(si[:index], "## Active"),
146
+ abandoned: index_section_ids(si[:index], "## Abandoned"),
147
+ completed: index_section_ids(si[:index], "## Completed"),
148
+ }
149
+ comp = completion_dates(si[:index])
150
+ intent_dirs(si[:store]).each do |d|
151
+ rec = parse_intent(si, d, idx)
152
+ next unless rec
153
+ rec[:completed_on] = comp[rec[:id]] || ""
154
+ all << rec
155
+ done_ids[[rec[:scope], rec[:id]]] = true if rec[:status] == "completed"
156
+ end
157
+ end
158
+ [all, done_ids]
159
+ end
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Classification — deterministic Value x Effort + disposition
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def intent_type(rec)
166
+ tags = rec[:tags].map(&:to_s)
167
+ return "research" if tags.include?("research")
168
+ return "exploration" if tags.include?("exploration")
169
+ return "bugfix" if tags.include?("bugfix")
170
+ "implementation"
171
+ end
172
+
173
+ def root_intent?(id)
174
+ id.match?(/\A\d+\z/)
175
+ end
176
+
177
+ def folgezettel_depth(id)
178
+ id.scan(/\d+|[a-z]+/).size
179
+ end
180
+
181
+ def lifecycle_stage(rec)
182
+ return "done" if rec[:outcome]
183
+ return "exec" if rec[:checklist]
184
+ return "how" if rec[:plan]
185
+ return "why" if rec[:spec]
186
+ return "why" if rec[:body_has_context]
187
+ "what"
188
+ end
189
+
190
+ # Effort -> :small | :big
191
+ # Small when the work is bounded: a non-implementation type, an already-scoped intent
192
+ # (plan/checklist exists), or a deep refinement branch. Otherwise big.
193
+ def effort_of(rec, type)
194
+ return :small if %w[research exploration bugfix].include?(type)
195
+ return :small if rec[:plan] || rec[:checklist]
196
+ return :small if folgezettel_depth(rec[:id]) >= 4
197
+ :big
198
+ end
199
+
200
+ # Value -> :high | :low (explicit frontmatter field wins).
201
+ # High is deliberately rare: an explicit stamp, or a human-authored root idea that has
202
+ # already spawned follow-on work (chain non-empty) — i.e. a strategic theme the user owns.
203
+ def value_of(rec)
204
+ case rec[:value_field]
205
+ when "high" then return :high
206
+ when "low" then return :low
207
+ end
208
+ return :high if rec[:author] == "human" && root_intent?(rec[:id]) && !rec[:chain].empty?
209
+ :low
210
+ end
211
+
212
+ def flags_of(rec, done_ids)
213
+ flags = []
214
+ flags << "in-progress" if rec[:savepoint] || rec[:checklist_partial]
215
+ if !rec[:sources].empty? && rec[:sources].any? { |s| done_ids[[rec[:scope], s]] }
216
+ flags << "unblocked"
217
+ end
218
+ age = stale_age(rec)
219
+ flags << "stale" if rec[:status] == "future" && age && age >= STALE_DAYS
220
+ flags
221
+ end
222
+
223
+ def stale_age(rec)
224
+ return nil if rec[:created].nil? || rec[:created].empty?
225
+ (today - Date.parse(rec[:created])).to_i
226
+ rescue StandardError
227
+ nil
228
+ end
229
+
230
+ QUADRANTS = {
231
+ [:high, :small] => "quick_win",
232
+ [:high, :big] => "next_big",
233
+ [:low, :small] => "defer",
234
+ [:low, :big] => "triage",
235
+ }.freeze
236
+
237
+ # Disposition verb: research overrides by type; else by quadrant.
238
+ def disposition_of(type, quadrant)
239
+ return "research" if %w[research exploration].include?(type)
240
+ case quadrant
241
+ when "next_big" then "drive"
242
+ when "quick_win", "defer" then "defer"
243
+ else "triage"
244
+ end
245
+ end
246
+
247
+ def classify(rec, done_ids)
248
+ type = intent_type(rec)
249
+ value = value_of(rec)
250
+ effort = effort_of(rec, type)
251
+ quadrant = QUADRANTS[[value, effort]]
252
+ disposition = disposition_of(type, quadrant)
253
+ flags = flags_of(rec, done_ids)
254
+ rec.merge(
255
+ type: type, value: value, effort: effort, quadrant: quadrant,
256
+ lifecycle: lifecycle_stage(rec), flags: flags, disposition: disposition,
257
+ )
258
+ end
259
+
260
+ # Only intents that are open work (not done/abandoned).
261
+ def actionable?(rec)
262
+ %w[future active].include?(rec[:status])
263
+ end
264
+
265
+ # Priority rank key: value, flag urgency, effort.
266
+ def rank_key(rec)
267
+ v = rec[:value] == :high ? 0 : 1
268
+ f = if rec[:flags].include?("in-progress") then 0
269
+ elsif rec[:flags].include?("unblocked") then 1
270
+ else 2 end
271
+ e = rec[:effort] == :small ? 0 : 1
272
+ [v, f, e, rec[:id]]
273
+ end
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Glyphs
277
+ # ---------------------------------------------------------------------------
278
+
279
+ LIFECYCLE_GLYPH = { "what" => "○", "why" => "◔", "how" => "◑", "exec" => "◕", "done" => "●" }.freeze
280
+ DISPOSITION_GLYPH = { "drive" => "▸", "defer" => "⇢", "research" => "⊙", "triage" => "⚑" }.freeze
281
+ LEGEND = "legend ○ What ◔ Why ◑ How ◕ Exec ● Done │ ▸drive ⇢defer ⊙research ⚑triage │ ⇡unblocked"
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Rendering helpers
285
+ # ---------------------------------------------------------------------------
286
+
287
+ # Pad/truncate to a fixed cell width (glyphs counted as one cell).
288
+ def pad(str, width)
289
+ s = str.to_s
290
+ s = "#{s[0, width - 1]}…" if s.length > width
291
+ s + (" " * (width - s.length))
292
+ end
293
+
294
+ CELL_CAP = 6
295
+
296
+ def matrix(records, scope_tag: false)
297
+ cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
298
+ research = []
299
+ records.each do |r|
300
+ if %w[research exploration].include?(r[:type])
301
+ research << r
302
+ else
303
+ cells[r[:quadrant]] << r
304
+ end
305
+ end
306
+ cells.each_value { |list| list.sort_by! { |r| rank_key(r) } }
307
+ research.sort_by! { |r| rank_key(r) }
308
+
309
+ cw = 30
310
+ out = []
311
+ out << " EFFORT → small big"
312
+ out << " ┌#{'─' * cw}┬#{'─' * cw}┐"
313
+ out << " value │ #{pad('QUICK WIN', cw - 1)}│ #{pad('★ NEXT BIG THING', cw - 1)}│"
314
+ out.concat(cell_rows(cells["quick_win"], cells["next_big"], cw, scope_tag))
315
+ out << " ├#{'─' * cw}┼#{'─' * cw}┤"
316
+ out << " low │ #{pad('DEFER → agent ⇢', cw - 1)}│ #{pad('TRIAGE / question ⚑', cw - 1)}│"
317
+ out.concat(cell_rows(cells["defer"], cells["triage"], cw, scope_tag))
318
+ out << " └#{'─' * cw}┴#{'─' * cw}┘"
319
+ unless research.empty?
320
+ names = research.map { |r| "#{r[:id]} #{r[:intent]}" }.join(" · ")
321
+ out << " RESEARCH → agent ⊙ #{names}"
322
+ end
323
+ out
324
+ end
325
+
326
+ def cell_entry(rec, width, scope_tag)
327
+ return pad("", width) if rec.nil?
328
+ flag = rec[:flags].include?("unblocked") ? " ⇡" : ""
329
+ scope = scope_tag && !rec[:scope].to_s.empty? ? " (#{rec[:scope].sub('project:', '')})" : ""
330
+ text = rec[:id].to_s.empty? ? " #{rec[:intent]}" : " #{rec[:id]} #{rec[:intent]}"
331
+ pad("#{text}#{flag}#{scope}", width)
332
+ end
333
+
334
+ # Cap a cell's list to CELL_CAP entries, appending a "+N more" marker when truncated.
335
+ def cap_cell(list)
336
+ return list if list.size <= CELL_CAP
337
+ list.first(CELL_CAP) + [{ id: "", intent: "+#{list.size - CELL_CAP} more", flags: [], scope: "" }]
338
+ end
339
+
340
+ def cell_rows(left, right, cw, scope_tag)
341
+ left = cap_cell(left)
342
+ right = cap_cell(right)
343
+ rows = [left.size, right.size, 1].max
344
+ (0...rows).map do |i|
345
+ " │#{cell_entry(left[i], cw, scope_tag)}│#{cell_entry(right[i], cw, scope_tag)}│"
346
+ end
347
+ end
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # Text renderers
351
+ # ---------------------------------------------------------------------------
352
+
353
+ def header(title, right = "")
354
+ pad_to = 70
355
+ inner = pad(" PLASTIC · #{title}", pad_to - right.length - 2) + right
356
+ ["╔#{'═' * pad_to}╗", "║#{pad(inner, pad_to)}║", "╚#{'═' * pad_to}╝"]
357
+ end
358
+
359
+ def render_continue(records)
360
+ out = []
361
+ out.concat(header("continue", today.to_s))
362
+ out << ""
363
+ out << "WHERE WE ARE · active + last touched"
364
+ out << ("─" * 70)
365
+ active = records.select { |r| r[:status] == "active" }.sort_by { |r| rank_key(r) }
366
+ recent = records.select { |r| r[:status] == "completed" && !r[:completed_on].empty? }
367
+ .sort_by { |r| r[:completed_on] }.reverse.first(3)
368
+ active.each do |r|
369
+ out << " #{LIFECYCLE_GLYPH[r[:lifecycle]]} #{pad(r[:id], 6)}#{pad(r[:intent], 38)} #{pad(r[:scope].sub('project:', ''), 9)} #{DISPOSITION_GLYPH[r[:disposition]]} #{r[:disposition]}"
370
+ end
371
+ recent.each do |r|
372
+ out << " ● #{pad(r[:id], 6)}#{pad(r[:intent], 38)} #{pad(r[:scope].sub('project:', ''), 9)} done"
373
+ end
374
+ out << " (none)" if active.empty? && recent.empty?
375
+ out << ""
376
+ out << "WHERE WE GO NEXT · value × effort (all scopes)"
377
+ open = records.select { |r| actionable?(r) && r[:status] != "active" }
378
+ out.concat(matrix(open, scope_tag: true))
379
+ out << ""
380
+ out << LEGEND
381
+ out << "run plastic-dashboard project <slug> · plastic-auto (works the dispatchable queue)"
382
+ out.join("\n") + "\n"
383
+ end
384
+
385
+ def render_project(records, slug)
386
+ scoped = records.select { |r| r[:scope] == "project:#{slug}" }
387
+ active = scoped.select { |r| r[:status] == "active" }
388
+ nxt = scoped.count { |r| r[:status] == "future" }
389
+ out = []
390
+ out.concat(header("project:#{slug}", "#{scoped.size} intents · #{active.size} active · #{nxt} next"))
391
+ out << ""
392
+ if active.empty?
393
+ out << "ACTIVE (none)"
394
+ else
395
+ active.each do |r|
396
+ out << "ACTIVE #{LIFECYCLE_GLYPH[r[:lifecycle]]} #{pad(r[:id], 6)}#{pad(r[:intent], 32)}#{DISPOSITION_GLYPH[r[:disposition]]} #{r[:disposition]}"
397
+ end
398
+ end
399
+ out << ""
400
+ out << "WHERE WE GO NEXT · value × effort"
401
+ out.concat(matrix(scoped.select { |r| r[:status] == "future" }, scope_tag: false))
402
+ out << ""
403
+ out << LEGEND
404
+ out.join("\n") + "\n"
405
+ end
406
+
407
+ def render_all(records)
408
+ out = []
409
+ out.concat(header("all scopes", today.to_s))
410
+ out << ""
411
+ stores.map { |s| s[:scope] }.each do |scope|
412
+ scoped = records.select { |r| r[:scope] == scope }
413
+ next if scoped.empty?
414
+ a = scoped.count { |r| r[:status] == "active" }
415
+ f = scoped.count { |r| r[:status] == "future" }
416
+ d = scoped.count { |r| r[:status] == "completed" }
417
+ out << " #{pad(scope, 20)} #{pad("#{scoped.size} intents", 14)} active #{a} · next #{f} · done #{d}"
418
+ end
419
+ out << ""
420
+ out << LEGEND
421
+ out.join("\n") + "\n"
422
+ end
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # JSON renderer (agent / auto-mode contract)
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def render_json(records, scope_label)
429
+ ranked = records.select { |r| actionable?(r) }.sort_by { |r| rank_key(r) }
430
+ dispatchable = ranked.select { |r| %w[defer research].include?(r[:disposition]) }
431
+ human_only = ranked.select { |r| %w[drive triage].include?(r[:disposition]) }
432
+ nbt = ranked.find { |r| r[:disposition] == "drive" }
433
+ {
434
+ generated_for: "auto-mode",
435
+ scope: scope_label,
436
+ next_big_thing: nbt && nbt[:id],
437
+ dispatchable_queue: dispatchable.each_with_index.map do |r, i|
438
+ { id: r[:id], scope: r[:scope], disposition: r[:disposition], type: r[:type],
439
+ value: r[:value].to_s, effort: r[:effort].to_s, flags: r[:flags], rank: i + 1 }
440
+ end,
441
+ human_only: human_only.map { |r| r[:id] },
442
+ }
443
+ end
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # CLI
447
+ # ---------------------------------------------------------------------------
448
+
449
+ def main(argv)
450
+ json = argv.delete("--json")
451
+ mode = argv.shift || "continue"
452
+ slug = argv.shift
453
+
454
+ raw, done_ids = load_all
455
+ records = raw.map { |r| classify(r, done_ids) }
456
+
457
+ if json
458
+ subset = mode == "project" ? records.select { |r| r[:scope] == "project:#{slug}" } : records
459
+ label = mode == "project" ? "project:#{slug}" : "all"
460
+ puts JSON.pretty_generate(render_json(subset, label))
461
+ return 0
462
+ end
463
+
464
+ case mode
465
+ when "continue" then print render_continue(records)
466
+ when "project"
467
+ if slug.nil? || slug.empty?
468
+ warn "usage: dashboard.rb project <slug>"
469
+ return 2
470
+ end
471
+ print render_project(records, slug)
472
+ when "all" then print render_all(records)
473
+ else
474
+ warn "unknown mode: #{mode} (use continue|project <slug>|all)"
475
+ return 2
476
+ end
477
+ 0
478
+ end
479
+
480
+ exit(main(ARGV)) if $PROGRAM_NAME == __FILE__