@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (87) hide show
  1. package/PLASTIC.md +134 -469
  2. package/README.md +95 -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 +1 -10
  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 +12 -0
  12. package/hooks/gate-check +3 -1
  13. package/hooks/hooks.json +25 -4
  14. package/hooks/statusline +50 -10
  15. package/package.json +2 -2
  16. package/scripts/dashboard.rb +680 -0
  17. package/scripts/doctor.rb +1227 -0
  18. package/scripts/hook-auto-arm +51 -0
  19. package/scripts/hook-bash-gate +41 -0
  20. package/scripts/hook-code-gate +27 -0
  21. package/scripts/hook-continue +15 -114
  22. package/scripts/hook-gate-check +30 -34
  23. package/scripts/hook-session-start +121 -31
  24. package/scripts/install.rb +91 -487
  25. package/scripts/lib/boot_banner.rb +28 -0
  26. package/scripts/lib/bridge.rb +358 -10
  27. package/scripts/lib/installer_core.rb +747 -0
  28. package/scripts/lib/intent_validator.rb +90 -0
  29. package/scripts/lib/qmd_sync.rb +159 -0
  30. package/scripts/lib/store_provisioning.rb +100 -0
  31. package/scripts/migrate-to-global +1 -1
  32. package/scripts/provision-project-store +53 -0
  33. package/scripts/qmd-sync +92 -0
  34. package/scripts/select-update-target +93 -0
  35. package/scripts/uninstall.rb +53 -0
  36. package/scripts/update.rb +164 -0
  37. package/scripts/validate-intent +54 -0
  38. package/scripts/versions.rb +141 -0
  39. package/skills/_active-intent-gate.md +26 -0
  40. package/skills/add-project-store/SKILL.md +54 -0
  41. package/skills/auto/SKILL.md +69 -9
  42. package/skills/auto/evals/evals.json +92 -0
  43. package/skills/auto/references/agent-architecture.md +60 -0
  44. package/skills/brainstorming/SKILL.md +143 -0
  45. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  46. package/skills/continuing/SKILL.md +92 -82
  47. package/skills/continuing/evals/evals.json +136 -0
  48. package/skills/continuing/references/context-management.md +32 -0
  49. package/skills/creating-intent/SKILL.md +38 -9
  50. package/skills/creating-intent/references/lifecycle.md +74 -0
  51. package/skills/creating-intent/references/wikilinks.md +8 -0
  52. package/skills/creating-project/SKILL.md +40 -8
  53. package/skills/creating-project/references/hubs-projects.md +55 -0
  54. package/skills/dashboard/SKILL.md +121 -0
  55. package/skills/dashboard/templates/dashboard-global.md +31 -0
  56. package/skills/dashboard/templates/dashboard-project.md +40 -0
  57. package/skills/doctor/SKILL.md +159 -0
  58. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  59. package/skills/doctor/report.md +100 -0
  60. package/skills/evaluating-skills/SKILL.md +140 -0
  61. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  62. package/skills/evaluating-skills/evals/evals.json +75 -0
  63. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  64. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  65. package/skills/executing-plan/SKILL.md +5 -3
  66. package/skills/install/SKILL.md +69 -8
  67. package/skills/intent-curator/SKILL.md +3 -3
  68. package/skills/linking-intents/SKILL.md +5 -1
  69. package/skills/linking-intents/references/zettelkasten.md +33 -0
  70. package/skills/managing-index/SKILL.md +5 -1
  71. package/skills/releasing/SKILL.md +119 -18
  72. package/skills/releasing/references/deprecations.md +60 -0
  73. package/skills/research/SKILL.md +114 -0
  74. package/skills/savepoint/SKILL.md +46 -37
  75. package/skills/savepoint/references/context-management.md +32 -0
  76. package/skills/uninstall/SKILL.md +39 -28
  77. package/skills/update/SKILL.md +41 -36
  78. package/skills/versions/SKILL.md +65 -0
  79. package/skills/writing-instructions/SKILL.md +159 -0
  80. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  81. package/skills/writing-plans/SKILL.md +183 -0
  82. package/templates/agents.md +16 -0
  83. package/templates/outcome.md +13 -0
  84. package/templates/project.yml +5 -0
  85. package/templates/savepoint.md +14 -13
  86. package/templates/spec.md +25 -0
  87. package/bin/install.js +0 -29
@@ -0,0 +1,680 @@
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
+ require_relative "doctor"
26
+
27
+ PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
28
+
29
+ def today
30
+ s = ENV["DASHBOARD_TODAY"]
31
+ s && !s.empty? ? Date.parse(s) : Date.today
32
+ end
33
+
34
+ STALE_DAYS = 14
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Parsing — reuses the conventions in scripts/doctor.rb
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def parse_frontmatter(path)
41
+ return nil unless File.exist?(path)
42
+ content = File.read(path)
43
+ return nil unless content.start_with?("---")
44
+ parts = content.split("---", 3)
45
+ return nil if parts.length < 3
46
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ # Returns the set of intent ids listed under a given INDEX.md section.
52
+ def index_section_ids(index_path, header)
53
+ return [] unless File.exist?(index_path)
54
+ body = File.read(index_path)
55
+ seg = body[/^#{Regexp.escape(header)}\s*\n(.*?)(?=^## |\z)/m, 1]
56
+ return [] unless seg
57
+ seg.scan(/^- \[([^\]\s]+)/).flatten
58
+ end
59
+
60
+ # Map of intent id -> completion date string, parsed from the "## Completed" section
61
+ # (lines like "- [12 — ...](...) — 2026-06-10"). Deterministic, content-derived.
62
+ def completion_dates(index_path)
63
+ return {} unless File.exist?(index_path)
64
+ body = File.read(index_path)
65
+ seg = body[/^## Completed\s*\n(.*?)(?=^## |\z)/m, 1] || ""
66
+ seg.scan(/^- \[([^\]\s]+).*?—\s*(\d{4}-\d{2}-\d{2})\s*$/).to_h
67
+ end
68
+
69
+ # All stores: global + every registered project. -> [{scope, store, index}]
70
+ def stores
71
+ list = []
72
+ global = File.join(PLASTIC_HOME, "store")
73
+ list << { scope: "global", store: global, index: File.join(PLASTIC_HOME, "INDEX.md") } if File.directory?(global)
74
+ projects_root = File.join(PLASTIC_HOME, "projects")
75
+ if File.directory?(projects_root)
76
+ Dir.children(projects_root).sort.each do |proj|
77
+ store = File.join(projects_root, proj, "store")
78
+ next unless File.directory?(store)
79
+ list << { scope: "project:#{proj}", store: store, index: File.join(projects_root, proj, "INDEX.md") }
80
+ end
81
+ end
82
+ list
83
+ end
84
+
85
+ def intent_dirs(store)
86
+ Dir.children(store).reject { |e| e.start_with?(".") }
87
+ .select { |e| File.directory?(File.join(store, e)) }
88
+ .sort
89
+ end
90
+
91
+ # Last-accessed timestamp: the timestamp of the last ISO8601 line in the
92
+ # deterministic savepoint ledger (intent 34); falls back to the created date.
93
+ ISO8601_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\b/
94
+
95
+ def last_accessed_at(dir, created)
96
+ sp = File.join(dir, "savepoint.md")
97
+ if File.exist?(sp)
98
+ File.readlines(sp).reverse_each do |line|
99
+ m = line.strip.match(ISO8601_RE)
100
+ return m[0] if m
101
+ end
102
+ end
103
+ return "#{created}T00:00:00Z" if created && !created.empty?
104
+ ""
105
+ end
106
+
107
+ # Parse one intent directory into a raw record.
108
+ def parse_intent(store_info, dir_name, status_index)
109
+ dir = File.join(store_info[:store], dir_name)
110
+ md = File.join(dir, "#{dir_name}.md")
111
+ fm = parse_frontmatter(md)
112
+ return nil unless fm && fm["id"]
113
+
114
+ id = fm["id"].to_s
115
+ has = ->(f) { File.exist?(File.join(dir, f)) }
116
+ body = File.exist?(md) ? File.read(md) : ""
117
+
118
+ status =
119
+ if has.("outcome.md") then "completed"
120
+ elsif status_index[:active].include?(id) then "active"
121
+ elsif status_index[:abandoned].include?(id) then "abandoned"
122
+ elsif status_index[:completed].include?(id) then "completed"
123
+ else "future"
124
+ end
125
+
126
+ {
127
+ id: id,
128
+ scope: store_info[:scope],
129
+ intent: (fm["intent"] || "").to_s.strip,
130
+ author: (fm["author"] || "").to_s,
131
+ tags: fm["tags"] || [],
132
+ sources: (fm["sources"] || []).map(&:to_s),
133
+ chain: (fm["chain"] || []).map(&:to_s),
134
+ created: (fm["created"].to_s rescue ""),
135
+ value_field: fm["value"] && fm["value"].to_s,
136
+ status: status,
137
+ spec: has.("spec.md"),
138
+ plan: has.("plan.md"),
139
+ checklist: has.("checklist.md"),
140
+ outcome: has.("outcome.md"),
141
+ savepoint: has.("savepoint.md"),
142
+ checklist_partial: has.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
143
+ body_has_context: body.include?("## Context"),
144
+ last_accessed_at: last_accessed_at(dir, (fm["created"].to_s rescue "")),
145
+ }
146
+ end
147
+
148
+ def checklist_partially_done?(path)
149
+ txt = File.read(path)
150
+ checked = txt.scan(/^\s*- \[x\]/i).size
151
+ total = txt.scan(/^\s*- \[[ x]\]/i).size
152
+ checked.positive? && checked < total
153
+ rescue StandardError
154
+ false
155
+ end
156
+
157
+ # Load every intent across all stores, with a completion-id set for unblock detection.
158
+ def load_all
159
+ all = []
160
+ done_ids = {}
161
+ stores.each do |si|
162
+ idx = {
163
+ active: index_section_ids(si[:index], "## Active"),
164
+ abandoned: index_section_ids(si[:index], "## Abandoned"),
165
+ completed: index_section_ids(si[:index], "## Completed"),
166
+ }
167
+ comp = completion_dates(si[:index])
168
+ intent_dirs(si[:store]).each do |d|
169
+ rec = parse_intent(si, d, idx)
170
+ next unless rec
171
+ rec[:completed_on] = comp[rec[:id]] || ""
172
+ all << rec
173
+ done_ids[[rec[:scope], rec[:id]]] = true if rec[:status] == "completed"
174
+ end
175
+ end
176
+ referenced = {}
177
+ all.each { |r| r[:sources].each { |s| referenced[[r[:scope], s]] = true } }
178
+ [all, done_ids, referenced]
179
+ end
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Classification — deterministic Value x Effort + disposition
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def intent_type(rec)
186
+ tags = rec[:tags].map(&:to_s)
187
+ return "research" if tags.include?("research")
188
+ return "exploration" if tags.include?("exploration")
189
+ return "bugfix" if tags.include?("bugfix")
190
+ "implementation"
191
+ end
192
+
193
+ def root_intent?(id)
194
+ id.match?(/\A\d+\z/)
195
+ end
196
+
197
+ def folgezettel_depth(id)
198
+ id.scan(/\d+|[a-z]+/).size
199
+ end
200
+
201
+ def lifecycle_stage(rec)
202
+ return "done" if rec[:outcome]
203
+ return "exec" if rec[:checklist]
204
+ return "how" if rec[:plan]
205
+ return "why" if rec[:spec]
206
+ return "why" if rec[:body_has_context]
207
+ "what"
208
+ end
209
+
210
+ # Effort -> :small | :big
211
+ # Small when the work is bounded: a non-implementation type, an already-scoped intent
212
+ # (plan/checklist exists), or a deep refinement branch. Otherwise big.
213
+ def effort_of(rec, type)
214
+ return :small if %w[research exploration bugfix].include?(type)
215
+ return :small if rec[:plan] || rec[:checklist]
216
+ return :small if folgezettel_depth(rec[:id]) >= 4
217
+ :big
218
+ end
219
+
220
+ # Value -> :high | :low (explicit frontmatter field wins).
221
+ # High is deliberately rare: an explicit stamp, or a human-authored root idea that has
222
+ # already spawned follow-on work (chain non-empty) — i.e. a strategic theme the user owns.
223
+ def value_of(rec, referenced = {})
224
+ case rec[:value_field]
225
+ when "high" then return :high
226
+ when "low" then return :low
227
+ end
228
+ return :high if rec[:author] == "human" && root_intent?(rec[:id])
229
+ return :high unless rec[:chain].empty?
230
+ return :high if referenced[[rec[:scope], rec[:id]]]
231
+ :low
232
+ end
233
+
234
+ def flags_of(rec, done_ids)
235
+ flags = []
236
+ flags << "in-progress" if rec[:savepoint] || rec[:checklist_partial]
237
+ if rec[:status] == "future" && !rec[:sources].empty? &&
238
+ rec[:sources].all? { |s| done_ids[[rec[:scope], s]] }
239
+ flags << "unblocked"
240
+ end
241
+ age = stale_age(rec)
242
+ flags << "stale" if rec[:status] == "future" && age && age >= STALE_DAYS
243
+ flags
244
+ end
245
+
246
+ def stale_age(rec)
247
+ return nil if rec[:created].nil? || rec[:created].empty?
248
+ (today - Date.parse(rec[:created])).to_i
249
+ rescue StandardError
250
+ nil
251
+ end
252
+
253
+ QUADRANTS = {
254
+ [:high, :small] => "quick_win",
255
+ [:high, :big] => "next_big",
256
+ [:low, :small] => "defer",
257
+ [:low, :big] => "triage",
258
+ }.freeze
259
+
260
+ # Disposition verb: research overrides by type; else by quadrant.
261
+ def disposition_of(type, quadrant)
262
+ return "research" if %w[research exploration].include?(type)
263
+ case quadrant
264
+ when "next_big" then "drive"
265
+ when "quick_win", "defer" then "defer"
266
+ else "triage"
267
+ end
268
+ end
269
+
270
+ def classify(rec, done_ids, referenced = {})
271
+ type = intent_type(rec)
272
+ value = value_of(rec, referenced)
273
+ effort = effort_of(rec, type)
274
+ quadrant = QUADRANTS[[value, effort]]
275
+ disposition = disposition_of(type, quadrant)
276
+ flags = flags_of(rec, done_ids)
277
+ rec.merge(
278
+ type: type, value: value, effort: effort, quadrant: quadrant,
279
+ lifecycle: lifecycle_stage(rec), flags: flags, disposition: disposition,
280
+ )
281
+ end
282
+
283
+ # Only intents that are open work (not done/abandoned).
284
+ def actionable?(rec)
285
+ %w[future active].include?(rec[:status])
286
+ end
287
+
288
+ # Priority rank key: value, flag urgency, effort.
289
+ def rank_key(rec)
290
+ v = rec[:value] == :high ? 0 : 1
291
+ f = if rec[:flags].include?("in-progress") then 0
292
+ elsif rec[:flags].include?("unblocked") then 1
293
+ else 2 end
294
+ e = rec[:effort] == :small ? 0 : 1
295
+ [v, f, e, rec[:id]]
296
+ end
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Glyphs
300
+ # ---------------------------------------------------------------------------
301
+
302
+ LIFECYCLE_GLYPH = { "what" => "○", "why" => "◔", "how" => "◑", "exec" => "◕", "done" => "●" }.freeze
303
+ DISPOSITION_GLYPH = { "drive" => "▸", "defer" => "⇢", "research" => "⊙", "triage" => "⚑" }.freeze
304
+ LEGEND = "legend ○ What ◔ Why ◑ How ◕ Exec ● Done │ ▸drive ⇢defer ⊙research ⚑triage │ ⇡unblocked"
305
+
306
+ # Markdown board glyphs (intent 37). Quadrant signatures + per-line bullets.
307
+ QUADRANT_BULLET = {
308
+ "quick_win" => "⚡", "next_big" => "★", "defer" => "→", "triage" => "⚑",
309
+ }.freeze
310
+ STATUS_GLYPH = { "active" => "◑", "completed" => "●", "future" => "○" }.freeze
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Rendering helpers
314
+ # ---------------------------------------------------------------------------
315
+
316
+ # Pad/truncate to a fixed cell width (glyphs counted as one cell).
317
+ def pad(str, width)
318
+ s = str.to_s
319
+ s = "#{s[0, width - 1]}…" if s.length > width
320
+ s + (" " * (width - s.length))
321
+ end
322
+
323
+ CELL_CAP = 6
324
+
325
+ def matrix(records, scope_tag: false)
326
+ cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
327
+ research = []
328
+ records.each do |r|
329
+ if %w[research exploration].include?(r[:type])
330
+ research << r
331
+ else
332
+ cells[r[:quadrant]] << r
333
+ end
334
+ end
335
+ cells.each_value { |list| list.sort_by! { |r| rank_key(r) } }
336
+ research.sort_by! { |r| rank_key(r) }
337
+
338
+ cw = 30
339
+ out = []
340
+ out << " EFFORT → small big"
341
+ out << " ┌#{'─' * cw}┬#{'─' * cw}┐"
342
+ out << " value │ #{pad('QUICK WIN', cw - 1)}│ #{pad('★ NEXT BIG THING', cw - 1)}│"
343
+ out.concat(cell_rows(cells["quick_win"], cells["next_big"], cw, scope_tag))
344
+ out << " ├#{'─' * cw}┼#{'─' * cw}┤"
345
+ out << " low │ #{pad('DEFER → agent ⇢', cw - 1)}│ #{pad('TRIAGE / question ⚑', cw - 1)}│"
346
+ out.concat(cell_rows(cells["defer"], cells["triage"], cw, scope_tag))
347
+ out << " └#{'─' * cw}┴#{'─' * cw}┘"
348
+ unless research.empty?
349
+ names = research.map { |r| "#{r[:id]} #{r[:intent]}" }.join(" · ")
350
+ out << " RESEARCH → agent ⊙ #{names}"
351
+ end
352
+ out
353
+ end
354
+
355
+ def cell_entry(rec, width, scope_tag)
356
+ return pad("", width) if rec.nil?
357
+ flag = rec[:flags].include?("unblocked") ? " ⇡" : ""
358
+ scope = scope_tag && !rec[:scope].to_s.empty? ? " (#{rec[:scope].sub('project:', '')})" : ""
359
+ text = rec[:id].to_s.empty? ? " #{rec[:intent]}" : " #{rec[:id]} #{rec[:intent]}"
360
+ pad("#{text}#{flag}#{scope}", width)
361
+ end
362
+
363
+ # Cap a cell's list to CELL_CAP entries, appending a "+N more" marker when truncated.
364
+ def cap_cell(list)
365
+ return list if list.size <= CELL_CAP
366
+ list.first(CELL_CAP) + [{ id: "", intent: "+#{list.size - CELL_CAP} more", flags: [], scope: "" }]
367
+ end
368
+
369
+ def cell_rows(left, right, cw, scope_tag)
370
+ left = cap_cell(left)
371
+ right = cap_cell(right)
372
+ rows = [left.size, right.size, 1].max
373
+ (0...rows).map do |i|
374
+ " │#{cell_entry(left[i], cw, scope_tag)}│#{cell_entry(right[i], cw, scope_tag)}│"
375
+ end
376
+ end
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Text renderers
380
+ # ---------------------------------------------------------------------------
381
+
382
+ def header(title, right = "")
383
+ pad_to = 70
384
+ inner = pad(" PLASTIC · #{title}", pad_to - right.length - 2) + right
385
+ ["╔#{'═' * pad_to}╗", "║#{pad(inner, pad_to)}║", "╚#{'═' * pad_to}╝"]
386
+ end
387
+
388
+ def render_continue(records)
389
+ out = []
390
+ out.concat(header("continue", today.to_s))
391
+ out << ""
392
+ out << "WHERE WE ARE · active + last touched"
393
+ out << ("─" * 70)
394
+ active = records.select { |r| r[:status] == "active" }.sort_by { |r| rank_key(r) }
395
+ recent = records.select { |r| r[:status] == "completed" && !r[:completed_on].empty? }
396
+ .sort_by { |r| r[:completed_on] }.reverse.first(3)
397
+ active.each do |r|
398
+ 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]}"
399
+ end
400
+ recent.each do |r|
401
+ out << " ● #{pad(r[:id], 6)}#{pad(r[:intent], 38)} #{pad(r[:scope].sub('project:', ''), 9)} done"
402
+ end
403
+ out << " (none)" if active.empty? && recent.empty?
404
+ out << ""
405
+ out << "WHERE WE GO NEXT · value × effort (all scopes)"
406
+ open = records.select { |r| actionable?(r) && r[:status] != "active" }
407
+ out.concat(matrix(open, scope_tag: true))
408
+ out << ""
409
+ out << LEGEND
410
+ out << "run plastic-dashboard project <slug> · plastic-auto (works the dispatchable queue)"
411
+ out.join("\n") + "\n"
412
+ end
413
+
414
+ def render_project(records, slug)
415
+ scoped = records.select { |r| r[:scope] == "project:#{slug}" }
416
+ active = scoped.select { |r| r[:status] == "active" }
417
+ nxt = scoped.count { |r| r[:status] == "future" }
418
+ out = []
419
+ out.concat(header("project:#{slug}", "#{scoped.size} intents · #{active.size} active · #{nxt} next"))
420
+ out << ""
421
+ if active.empty?
422
+ out << "ACTIVE (none)"
423
+ else
424
+ active.each do |r|
425
+ out << "ACTIVE #{LIFECYCLE_GLYPH[r[:lifecycle]]} #{pad(r[:id], 6)}#{pad(r[:intent], 32)}#{DISPOSITION_GLYPH[r[:disposition]]} #{r[:disposition]}"
426
+ end
427
+ end
428
+ out << ""
429
+ out << "WHERE WE GO NEXT · value × effort"
430
+ out.concat(matrix(scoped.select { |r| r[:status] == "future" }, scope_tag: false))
431
+ out << ""
432
+ out << LEGEND
433
+ out.join("\n") + "\n"
434
+ end
435
+
436
+ def render_all(records)
437
+ out = []
438
+ out.concat(header("all scopes", today.to_s))
439
+ out << ""
440
+ stores.map { |s| s[:scope] }.each do |scope|
441
+ scoped = records.select { |r| r[:scope] == scope }
442
+ next if scoped.empty?
443
+ a = scoped.count { |r| r[:status] == "active" }
444
+ f = scoped.count { |r| r[:status] == "future" }
445
+ d = scoped.count { |r| r[:status] == "completed" }
446
+ out << " #{pad(scope, 20)} #{pad("#{scoped.size} intents", 14)} active #{a} · next #{f} · done #{d}"
447
+ end
448
+ out << ""
449
+ out << LEGEND
450
+ out.join("\n") + "\n"
451
+ end
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Store health — runs doctor's scoped store check on dashboard load.
455
+ #
456
+ # Each board load runs `doctor --store <scope>` (global board -> :global,
457
+ # project board -> the slug) and surfaces a compact store-health line in the
458
+ # payload. Invoked IN-PROCESS (Doctor.new + run_store_checks) rather than
459
+ # shelling out: it is hermetic for tests (same PLASTIC_HOME), faster (no second
460
+ # Ruby boot), and avoids parsing a subprocess's JSON. Non-fatal by contract: a
461
+ # warn/fail result is data only and never crashes the board or changes its exit.
462
+ # ---------------------------------------------------------------------------
463
+
464
+ def store_health(scope)
465
+ result = Doctor.new(plastic_home: PLASTIC_HOME).run_store_checks(scope)
466
+ failing = (result[:checks] || []).reject { |c| c[:status] == "pass" }
467
+ .map { |c| c[:name] }
468
+ {
469
+ scope: scope.is_a?(Symbol) ? scope.to_s : scope,
470
+ status: result[:status],
471
+ summary: result[:summary],
472
+ failing_checks: failing,
473
+ }
474
+ rescue StandardError => e
475
+ # Never let a store-health probe take down the dashboard.
476
+ { scope: scope.is_a?(Symbol) ? scope.to_s : scope,
477
+ status: "warn", summary: { pass: 0, warn: 1, fail: 0, total: 1 },
478
+ failing_checks: ["store_health_probe_error"], error: e.message }
479
+ end
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # Markdown-board data payload (intent 37) — heavy side; the skill fills a
483
+ # Markdown template from this and presents it. Deterministic, golden-tested.
484
+ # ---------------------------------------------------------------------------
485
+
486
+ # Descending sort key for an ISO8601 / date string without reversing arrays.
487
+ def invert_ts(ts)
488
+ ts.to_s.ljust(20).chars.map { |c| 255 - c.ord }
489
+ end
490
+
491
+ def within_24h?(rec)
492
+ ts = rec[:last_accessed_at]
493
+ return false if ts.nil? || ts.empty?
494
+ d = (Date.parse(ts[0, 10]) rescue nil)
495
+ d && d >= (today - 1)
496
+ end
497
+
498
+ def worked_row(rec, project_scope)
499
+ glyph = STATUS_GLYPH[rec[:status]]
500
+ proj = rec[:scope] == "global" ? "global" : rec[:scope].sub("project:", "")
501
+ status_word = rec[:status] == "completed" ? "done" : rec[:status]
502
+ prefix = project_scope ? "" : "#{proj} | "
503
+ {
504
+ id: rec[:id], status: rec[:status], glyph: glyph,
505
+ last_accessed_at: rec[:last_accessed_at],
506
+ line: "#{glyph} #{prefix}#{status_word}: #{rec[:id]} #{rec[:intent]}".strip,
507
+ }
508
+ end
509
+
510
+ def recently_worked(records, project_scope: nil)
511
+ pool = records.select do |r|
512
+ %w[active completed].include?(r[:status]) && within_24h?(r) &&
513
+ (project_scope.nil? || r[:scope] == project_scope)
514
+ end
515
+ ordered = pool.sort_by { |r| [r[:status] == "active" ? 0 : 1, invert_ts(r[:last_accessed_at])] }
516
+ ordered = ordered.first(5) if ordered.size > 15
517
+ ordered.map { |r| worked_row(r, project_scope) }
518
+ end
519
+
520
+ def intent_line(rec, bullet)
521
+ note = rec[:status] == "active" ? " (#{rec[:lifecycle].to_s.capitalize})" : ""
522
+ { id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
523
+ scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{rec[:intent]}#{note}".rstrip }
524
+ end
525
+
526
+ def matrix_data(records)
527
+ cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
528
+ research = []
529
+ records.each do |r|
530
+ if %w[research exploration].include?(r[:type]) then research << r
531
+ else cells[r[:quadrant]] << r end
532
+ end
533
+ by_created_desc = ->(list) { list.sort_by { |r| invert_ts(r[:created]) } }
534
+ out = {}
535
+ cells.each { |q, list| out[q] = by_created_desc.call(list).map { |r| intent_line(r, QUADRANT_BULLET[q]) } }
536
+ out["research"] = by_created_desc.call(research).map { |r| intent_line(r, "🔬") }
537
+ out
538
+ end
539
+
540
+ def short_description(scope)
541
+ return "" unless scope.start_with?("project:")
542
+ slug = scope.sub("project:", "")
543
+ agents = File.join(PLASTIC_HOME, "projects", slug, "AGENTS.md")
544
+ if File.exist?(agents)
545
+ File.readlines(agents).each do |l|
546
+ t = l.strip
547
+ next if t.empty? || t.start_with?("#", ">")
548
+ return t[0, 60]
549
+ end
550
+ end
551
+ slug
552
+ end
553
+
554
+ def project_summaries(records)
555
+ scopes = records.map { |r| r[:scope] }.select { |s| s.start_with?("project:") }.uniq
556
+ rows = scopes.map do |scope|
557
+ scoped = records.select { |r| r[:scope] == scope }
558
+ {
559
+ slug: scope.sub("project:", ""),
560
+ description: short_description(scope),
561
+ active: scoped.count { |r| r[:status] == "active" },
562
+ done: scoped.count { |r| r[:status] == "completed" },
563
+ future: scoped.count { |r| r[:status] == "future" },
564
+ last_accessed_at: scoped.map { |r| r[:last_accessed_at] }.reject(&:empty?).max.to_s,
565
+ }
566
+ end
567
+ rows.sort_by { |r| invert_ts(r[:last_accessed_at]) }.first(5)
568
+ end
569
+
570
+ def counts_of(records)
571
+ { active: records.count { |r| r[:status] == "active" },
572
+ done: records.count { |r| r[:status] == "completed" },
573
+ future: records.count { |r| r[:status] == "future" } }
574
+ end
575
+
576
+ def render_data_global(records)
577
+ global = records.select { |r| r[:scope] == "global" }
578
+ matrix_pool = global.select { |r| actionable?(r) && r[:status] != "active" }
579
+ projs = project_summaries(records)
580
+ { mode: "global", date: today.to_s,
581
+ store_health: store_health(:global),
582
+ recently_worked: recently_worked(records),
583
+ matrix: matrix_data(matrix_pool),
584
+ counts: counts_of(global),
585
+ projects: projs,
586
+ project_totals: {
587
+ active: projs.sum { |p| p[:active] }, done: projs.sum { |p| p[:done] },
588
+ future: projs.sum { |p| p[:future] }
589
+ } }
590
+ end
591
+
592
+ def render_data_project(records, slug)
593
+ scope = "project:#{slug}"
594
+ scoped = records.select { |r| r[:scope] == scope }
595
+ matrix_pool = scoped.select { |r| r[:status] == "future" }
596
+ { mode: "project", date: today.to_s, slug: slug,
597
+ store_health: store_health(slug),
598
+ description: short_description(scope),
599
+ recently_worked: recently_worked(records, project_scope: scope),
600
+ matrix: matrix_data(matrix_pool),
601
+ counts: counts_of(scoped),
602
+ active: scoped.select { |r| r[:status] == "active" }
603
+ .sort_by { |r| invert_ts(r[:last_accessed_at]) }
604
+ .map { |r| intent_line(r, STATUS_GLYPH["active"]) },
605
+ future: scoped.select { |r| r[:status] == "future" }
606
+ .sort_by { |r| invert_ts(r[:created]) }
607
+ .map { |r| intent_line(r, STATUS_GLYPH["future"]) } }
608
+ end
609
+
610
+ # ---------------------------------------------------------------------------
611
+ # JSON renderer (agent / auto-mode contract)
612
+ # ---------------------------------------------------------------------------
613
+
614
+ def render_json(records, scope_label)
615
+ ranked = records.select { |r| actionable?(r) }.sort_by { |r| rank_key(r) }
616
+ dispatchable = ranked.select { |r| %w[defer research].include?(r[:disposition]) }
617
+ human_only = ranked.select { |r| %w[drive triage].include?(r[:disposition]) }
618
+ nbt = ranked.find { |r| r[:disposition] == "drive" }
619
+ {
620
+ generated_for: "auto-mode",
621
+ scope: scope_label,
622
+ next_big_thing: nbt && nbt[:id],
623
+ dispatchable_queue: dispatchable.each_with_index.map do |r, i|
624
+ { id: r[:id], scope: r[:scope], disposition: r[:disposition], type: r[:type],
625
+ value: r[:value].to_s, effort: r[:effort].to_s, flags: r[:flags], rank: i + 1 }
626
+ end,
627
+ human_only: human_only.map { |r| r[:id] },
628
+ }
629
+ end
630
+
631
+ # ---------------------------------------------------------------------------
632
+ # CLI
633
+ # ---------------------------------------------------------------------------
634
+
635
+ def main(argv)
636
+ json = argv.delete("--json")
637
+ data = argv.delete("--data")
638
+ mode = argv.shift || "continue"
639
+ slug = argv.shift
640
+
641
+ raw, done_ids, referenced = load_all
642
+ records = raw.map { |r| classify(r, done_ids, referenced) }
643
+
644
+ if data
645
+ payload = mode == "project" ? render_data_project(records, slug) : render_data_global(records)
646
+ puts JSON.pretty_generate(payload)
647
+ return 0
648
+ end
649
+
650
+ if json
651
+ subset = mode == "project" ? records.select { |r| r[:scope] == "project:#{slug}" } : records
652
+ label = mode == "project" ? "project:#{slug}" : "all"
653
+ payload = render_json(subset, label)
654
+ # Run the scoped store check on load, mirroring the --data board path:
655
+ # project board -> the slug; global/continue board -> :global. The `all`
656
+ # manifest spans every store, so its store-health probe is left to the
657
+ # per-scope boards (keeping the all-scopes auto-mode contract stable).
658
+ payload[:store_health] = store_health(slug) if mode == "project"
659
+ payload[:store_health] = store_health(:global) if mode == "continue"
660
+ puts JSON.pretty_generate(payload)
661
+ return 0
662
+ end
663
+
664
+ case mode
665
+ when "continue" then print render_continue(records)
666
+ when "project"
667
+ if slug.nil? || slug.empty?
668
+ warn "usage: dashboard.rb project <slug>"
669
+ return 2
670
+ end
671
+ print render_project(records, slug)
672
+ when "all" then print render_all(records)
673
+ else
674
+ warn "unknown mode: #{mode} (use continue|project <slug>|all)"
675
+ return 2
676
+ end
677
+ 0
678
+ end
679
+
680
+ exit(main(ARGV)) if $PROGRAM_NAME == __FILE__