@zalom/plastic 1.0.0-alpha.8 → 1.0.0-beta.1

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