@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/PLASTIC.md +163 -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 +687 -0
  23. package/scripts/doctor.rb +1054 -628
  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 -529
  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 +235 -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 +226 -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 +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. package/bin/install.js +0 -29
@@ -0,0 +1,687 @@
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, a human-authored root idea, or an intent
227
+ # that has SPAWNED follow-on work, i.e. a strategic theme the user owns. "Has spawned work"
228
+ # means a reciprocal (I1) edge: another intent lists this one in its `sources`, captured by
229
+ # `referenced`. A purely relational `chain` entry (D2, no reciprocal `sources`) does NOT
230
+ # count as spawned, so bare `chain` membership is not a high-value signal (intent 68).
231
+ def value_of(rec, referenced = {})
232
+ case rec[:value_field]
233
+ when "high" then return :high
234
+ when "low" then return :low
235
+ end
236
+ return :high if rec[:author] == "human" && root_intent?(rec[:id])
237
+ return :high if referenced[[rec[:scope], rec[:id]]]
238
+ :low
239
+ end
240
+
241
+ def flags_of(rec, done_ids)
242
+ flags = []
243
+ flags << "in-progress" if rec[:savepoint] || rec[:checklist_partial]
244
+ if rec[:status] == "future" && !rec[:sources].empty? &&
245
+ rec[:sources].all? { |s| done_ids[[rec[:scope], s]] }
246
+ flags << "unblocked"
247
+ end
248
+ age = stale_age(rec)
249
+ flags << "stale" if rec[:status] == "future" && age && age >= STALE_DAYS
250
+ flags
251
+ end
252
+
253
+ def stale_age(rec)
254
+ return nil if rec[:created].nil? || rec[:created].empty?
255
+ (today - Date.parse(rec[:created])).to_i
256
+ rescue StandardError
257
+ nil
258
+ end
259
+
260
+ QUADRANTS = {
261
+ [:high, :small] => "quick_win",
262
+ [:high, :big] => "next_big",
263
+ [:low, :small] => "defer",
264
+ [:low, :big] => "triage",
265
+ }.freeze
266
+
267
+ # Disposition verb: research overrides by type; else by quadrant.
268
+ def disposition_of(type, quadrant)
269
+ return "research" if %w[research exploration].include?(type)
270
+ case quadrant
271
+ when "next_big" then "drive"
272
+ when "quick_win", "defer" then "defer"
273
+ else "triage"
274
+ end
275
+ end
276
+
277
+ def classify(rec, done_ids, referenced = {})
278
+ type = intent_type(rec)
279
+ value = value_of(rec, referenced)
280
+ effort = effort_of(rec, type)
281
+ quadrant = QUADRANTS[[value, effort]]
282
+ disposition = disposition_of(type, quadrant)
283
+ flags = flags_of(rec, done_ids)
284
+ rec.merge(
285
+ type: type, value: value, effort: effort, quadrant: quadrant,
286
+ lifecycle: lifecycle_stage(rec), flags: flags, disposition: disposition,
287
+ )
288
+ end
289
+
290
+ # Only intents that are open work (not done/abandoned).
291
+ def actionable?(rec)
292
+ %w[future active].include?(rec[:status])
293
+ end
294
+
295
+ # Priority rank key: value, flag urgency, effort.
296
+ def rank_key(rec)
297
+ v = rec[:value] == :high ? 0 : 1
298
+ f = if rec[:flags].include?("in-progress") then 0
299
+ elsif rec[:flags].include?("unblocked") then 1
300
+ else 2 end
301
+ e = rec[:effort] == :small ? 0 : 1
302
+ [v, f, e, rec[:id]]
303
+ end
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Glyphs
307
+ # ---------------------------------------------------------------------------
308
+
309
+ LIFECYCLE_GLYPH = { "what" => "○", "why" => "◔", "how" => "◑", "exec" => "◕", "done" => "●" }.freeze
310
+ DISPOSITION_GLYPH = { "drive" => "▸", "defer" => "⇢", "research" => "⊙", "triage" => "⚑" }.freeze
311
+ LEGEND = "legend ○ What ◔ Why ◑ How ◕ Exec ● Done │ ▸drive ⇢defer ⊙research ⚑triage │ ⇡unblocked"
312
+
313
+ # Markdown board glyphs (intent 37). Quadrant signatures + per-line bullets.
314
+ QUADRANT_BULLET = {
315
+ "quick_win" => "⚡", "next_big" => "★", "defer" => "→", "triage" => "⚑",
316
+ }.freeze
317
+ STATUS_GLYPH = { "active" => "◑", "completed" => "●", "future" => "○" }.freeze
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Rendering helpers
321
+ # ---------------------------------------------------------------------------
322
+
323
+ # Pad/truncate to a fixed cell width (glyphs counted as one cell).
324
+ def pad(str, width)
325
+ s = str.to_s
326
+ s = "#{s[0, width - 1]}…" if s.length > width
327
+ s + (" " * (width - s.length))
328
+ end
329
+
330
+ CELL_CAP = 6
331
+
332
+ def matrix(records, scope_tag: false)
333
+ cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
334
+ research = []
335
+ records.each do |r|
336
+ if %w[research exploration].include?(r[:type])
337
+ research << r
338
+ else
339
+ cells[r[:quadrant]] << r
340
+ end
341
+ end
342
+ cells.each_value { |list| list.sort_by! { |r| rank_key(r) } }
343
+ research.sort_by! { |r| rank_key(r) }
344
+
345
+ cw = 30
346
+ out = []
347
+ out << " EFFORT → small big"
348
+ out << " ┌#{'─' * cw}┬#{'─' * cw}┐"
349
+ out << " value │ #{pad('QUICK WIN', cw - 1)}│ #{pad('★ NEXT BIG THING', cw - 1)}│"
350
+ out.concat(cell_rows(cells["quick_win"], cells["next_big"], cw, scope_tag))
351
+ out << " ├#{'─' * cw}┼#{'─' * cw}┤"
352
+ out << " low │ #{pad('DEFER → agent ⇢', cw - 1)}│ #{pad('TRIAGE / question ⚑', cw - 1)}│"
353
+ out.concat(cell_rows(cells["defer"], cells["triage"], cw, scope_tag))
354
+ out << " └#{'─' * cw}┴#{'─' * cw}┘"
355
+ unless research.empty?
356
+ names = research.map { |r| "#{r[:id]} #{r[:intent]}" }.join(" · ")
357
+ out << " RESEARCH → agent ⊙ #{names}"
358
+ end
359
+ out
360
+ end
361
+
362
+ def cell_entry(rec, width, scope_tag)
363
+ return pad("", width) if rec.nil?
364
+ flag = rec[:flags].include?("unblocked") ? " ⇡" : ""
365
+ scope = scope_tag && !rec[:scope].to_s.empty? ? " (#{rec[:scope].sub('project:', '')})" : ""
366
+ text = rec[:id].to_s.empty? ? " #{rec[:intent]}" : " #{rec[:id]} #{rec[:intent]}"
367
+ pad("#{text}#{flag}#{scope}", width)
368
+ end
369
+
370
+ # Cap a cell's list to CELL_CAP entries, appending a "+N more" marker when truncated.
371
+ def cap_cell(list)
372
+ return list if list.size <= CELL_CAP
373
+ list.first(CELL_CAP) + [{ id: "", intent: "+#{list.size - CELL_CAP} more", flags: [], scope: "" }]
374
+ end
375
+
376
+ def cell_rows(left, right, cw, scope_tag)
377
+ left = cap_cell(left)
378
+ right = cap_cell(right)
379
+ rows = [left.size, right.size, 1].max
380
+ (0...rows).map do |i|
381
+ " │#{cell_entry(left[i], cw, scope_tag)}│#{cell_entry(right[i], cw, scope_tag)}│"
382
+ end
383
+ end
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # Text renderers
387
+ # ---------------------------------------------------------------------------
388
+
389
+ def header(title, right = "")
390
+ pad_to = 70
391
+ inner = pad(" PLASTIC · #{title}", pad_to - right.length - 2) + right
392
+ ["╔#{'═' * pad_to}╗", "║#{pad(inner, pad_to)}║", "╚#{'═' * pad_to}╝"]
393
+ end
394
+
395
+ def render_continue(records)
396
+ out = []
397
+ out.concat(header("continue", today.to_s))
398
+ out << ""
399
+ out << "WHERE WE ARE · active + last touched"
400
+ out << ("─" * 70)
401
+ active = records.select { |r| r[:status] == "active" }.sort_by { |r| rank_key(r) }
402
+ recent = records.select { |r| r[:status] == "completed" && !r[:completed_on].empty? }
403
+ .sort_by { |r| r[:completed_on] }.reverse.first(3)
404
+ active.each do |r|
405
+ 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]}"
406
+ end
407
+ recent.each do |r|
408
+ out << " ● #{pad(r[:id], 6)}#{pad(r[:intent], 38)} #{pad(r[:scope].sub('project:', ''), 9)} done"
409
+ end
410
+ out << " (none)" if active.empty? && recent.empty?
411
+ out << ""
412
+ out << "WHERE WE GO NEXT · value × effort (all scopes)"
413
+ open = records.select { |r| actionable?(r) && r[:status] != "active" }
414
+ out.concat(matrix(open, scope_tag: true))
415
+ out << ""
416
+ out << LEGEND
417
+ out << "run plastic-dashboard project <slug> · plastic-auto (works the dispatchable queue)"
418
+ out.join("\n") + "\n"
419
+ end
420
+
421
+ def render_project(records, slug)
422
+ scoped = records.select { |r| r[:scope] == "project:#{slug}" }
423
+ active = scoped.select { |r| r[:status] == "active" }
424
+ nxt = scoped.count { |r| r[:status] == "future" }
425
+ out = []
426
+ out.concat(header("project:#{slug}", "#{scoped.size} intents · #{active.size} active · #{nxt} next"))
427
+ out << ""
428
+ if active.empty?
429
+ out << "ACTIVE (none)"
430
+ else
431
+ active.each do |r|
432
+ out << "ACTIVE #{LIFECYCLE_GLYPH[r[:lifecycle]]} #{pad(r[:id], 6)}#{pad(r[:intent], 32)}#{DISPOSITION_GLYPH[r[:disposition]]} #{r[:disposition]}"
433
+ end
434
+ end
435
+ out << ""
436
+ out << "WHERE WE GO NEXT · value × effort"
437
+ out.concat(matrix(scoped.select { |r| r[:status] == "future" }, scope_tag: false))
438
+ out << ""
439
+ out << LEGEND
440
+ out.join("\n") + "\n"
441
+ end
442
+
443
+ def render_all(records)
444
+ out = []
445
+ out.concat(header("all scopes", today.to_s))
446
+ out << ""
447
+ stores.map { |s| s[:scope] }.each do |scope|
448
+ scoped = records.select { |r| r[:scope] == scope }
449
+ next if scoped.empty?
450
+ a = scoped.count { |r| r[:status] == "active" }
451
+ f = scoped.count { |r| r[:status] == "future" }
452
+ d = scoped.count { |r| r[:status] == "completed" }
453
+ out << " #{pad(scope, 20)} #{pad("#{scoped.size} intents", 14)} active #{a} · next #{f} · done #{d}"
454
+ end
455
+ out << ""
456
+ out << LEGEND
457
+ out.join("\n") + "\n"
458
+ end
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # Store health — runs doctor's scoped store check on dashboard load.
462
+ #
463
+ # Each board load runs `doctor --store <scope>` (global board -> :global,
464
+ # project board -> the slug) and surfaces a compact store-health line in the
465
+ # payload. Invoked IN-PROCESS (Doctor.new + run_store_checks) rather than
466
+ # shelling out: it is hermetic for tests (same PLASTIC_HOME), faster (no second
467
+ # Ruby boot), and avoids parsing a subprocess's JSON. Non-fatal by contract: a
468
+ # warn/fail result is data only and never crashes the board or changes its exit.
469
+ # ---------------------------------------------------------------------------
470
+
471
+ def store_health(scope)
472
+ result = Doctor.new(plastic_home: PLASTIC_HOME).run_store_checks(scope)
473
+ failing = (result[:checks] || []).reject { |c| c[:status] == "pass" }
474
+ .map { |c| c[:name] }
475
+ {
476
+ scope: scope.is_a?(Symbol) ? scope.to_s : scope,
477
+ status: result[:status],
478
+ summary: result[:summary],
479
+ failing_checks: failing,
480
+ }
481
+ rescue StandardError => e
482
+ # Never let a store-health probe take down the dashboard.
483
+ { scope: scope.is_a?(Symbol) ? scope.to_s : scope,
484
+ status: "warn", summary: { pass: 0, warn: 1, fail: 0, total: 1 },
485
+ failing_checks: ["store_health_probe_error"], error: e.message }
486
+ end
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # Markdown-board data payload (intent 37) — heavy side; the skill fills a
490
+ # Markdown template from this and presents it. Deterministic, golden-tested.
491
+ # ---------------------------------------------------------------------------
492
+
493
+ # Descending sort key for an ISO8601 / date string without reversing arrays.
494
+ def invert_ts(ts)
495
+ ts.to_s.ljust(20).chars.map { |c| 255 - c.ord }
496
+ end
497
+
498
+ def within_24h?(rec)
499
+ ts = rec[:last_accessed_at]
500
+ return false if ts.nil? || ts.empty?
501
+ d = (Date.parse(ts[0, 10]) rescue nil)
502
+ d && d >= (today - 1)
503
+ end
504
+
505
+ def worked_row(rec, project_scope)
506
+ glyph = STATUS_GLYPH[rec[:status]]
507
+ proj = rec[:scope] == "global" ? "global" : rec[:scope].sub("project:", "")
508
+ status_word = rec[:status] == "completed" ? "done" : rec[:status]
509
+ prefix = project_scope ? "" : "#{proj} | "
510
+ {
511
+ id: rec[:id], status: rec[:status], glyph: glyph,
512
+ last_accessed_at: rec[:last_accessed_at],
513
+ line: "#{glyph} #{prefix}#{status_word}: #{rec[:id]} #{rec[:intent]}".strip,
514
+ }
515
+ end
516
+
517
+ def recently_worked(records, project_scope: nil)
518
+ pool = records.select do |r|
519
+ %w[active completed].include?(r[:status]) && within_24h?(r) &&
520
+ (project_scope.nil? || r[:scope] == project_scope)
521
+ end
522
+ ordered = pool.sort_by { |r| [r[:status] == "active" ? 0 : 1, invert_ts(r[:last_accessed_at])] }
523
+ ordered = ordered.first(5) if ordered.size > 15
524
+ ordered.map { |r| worked_row(r, project_scope) }
525
+ end
526
+
527
+ def intent_line(rec, bullet)
528
+ note = rec[:status] == "active" ? " (#{rec[:lifecycle].to_s.capitalize})" : ""
529
+ { id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
530
+ scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{rec[:intent]}#{note}".rstrip }
531
+ end
532
+
533
+ def matrix_data(records)
534
+ cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
535
+ research = []
536
+ records.each do |r|
537
+ if %w[research exploration].include?(r[:type]) then research << r
538
+ else cells[r[:quadrant]] << r end
539
+ end
540
+ by_created_desc = ->(list) { list.sort_by { |r| invert_ts(r[:created]) } }
541
+ out = {}
542
+ cells.each { |q, list| out[q] = by_created_desc.call(list).map { |r| intent_line(r, QUADRANT_BULLET[q]) } }
543
+ out["research"] = by_created_desc.call(research).map { |r| intent_line(r, "🔬") }
544
+ out
545
+ end
546
+
547
+ def short_description(scope)
548
+ return "" unless scope.start_with?("project:")
549
+ slug = scope.sub("project:", "")
550
+ agents = File.join(PLASTIC_HOME, "projects", slug, "AGENTS.md")
551
+ if File.exist?(agents)
552
+ File.readlines(agents).each do |l|
553
+ t = l.strip
554
+ next if t.empty? || t.start_with?("#", ">")
555
+ return t[0, 60]
556
+ end
557
+ end
558
+ slug
559
+ end
560
+
561
+ def project_summaries(records)
562
+ scopes = records.map { |r| r[:scope] }.select { |s| s.start_with?("project:") }.uniq
563
+ rows = scopes.map do |scope|
564
+ scoped = records.select { |r| r[:scope] == scope }
565
+ {
566
+ slug: scope.sub("project:", ""),
567
+ description: short_description(scope),
568
+ active: scoped.count { |r| r[:status] == "active" },
569
+ done: scoped.count { |r| r[:status] == "completed" },
570
+ future: scoped.count { |r| r[:status] == "future" },
571
+ last_accessed_at: scoped.map { |r| r[:last_accessed_at] }.reject(&:empty?).max.to_s,
572
+ }
573
+ end
574
+ rows.sort_by { |r| invert_ts(r[:last_accessed_at]) }.first(5)
575
+ end
576
+
577
+ def counts_of(records)
578
+ { active: records.count { |r| r[:status] == "active" },
579
+ done: records.count { |r| r[:status] == "completed" },
580
+ future: records.count { |r| r[:status] == "future" } }
581
+ end
582
+
583
+ def render_data_global(records)
584
+ global = records.select { |r| r[:scope] == "global" }
585
+ matrix_pool = global.select { |r| actionable?(r) && r[:status] != "active" }
586
+ projs = project_summaries(records)
587
+ { mode: "global", date: today.to_s,
588
+ store_health: store_health(:global),
589
+ recently_worked: recently_worked(records),
590
+ matrix: matrix_data(matrix_pool),
591
+ counts: counts_of(global),
592
+ projects: projs,
593
+ project_totals: {
594
+ active: projs.sum { |p| p[:active] }, done: projs.sum { |p| p[:done] },
595
+ future: projs.sum { |p| p[:future] }
596
+ } }
597
+ end
598
+
599
+ def render_data_project(records, slug)
600
+ scope = "project:#{slug}"
601
+ scoped = records.select { |r| r[:scope] == scope }
602
+ matrix_pool = scoped.select { |r| r[:status] == "future" }
603
+ { mode: "project", date: today.to_s, slug: slug,
604
+ store_health: store_health(slug),
605
+ description: short_description(scope),
606
+ recently_worked: recently_worked(records, project_scope: scope),
607
+ matrix: matrix_data(matrix_pool),
608
+ counts: counts_of(scoped),
609
+ active: scoped.select { |r| r[:status] == "active" }
610
+ .sort_by { |r| invert_ts(r[:last_accessed_at]) }
611
+ .map { |r| intent_line(r, STATUS_GLYPH["active"]) },
612
+ future: scoped.select { |r| r[:status] == "future" }
613
+ .sort_by { |r| invert_ts(r[:created]) }
614
+ .map { |r| intent_line(r, STATUS_GLYPH["future"]) } }
615
+ end
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # JSON renderer (agent / auto-mode contract)
619
+ # ---------------------------------------------------------------------------
620
+
621
+ def render_json(records, scope_label)
622
+ ranked = records.select { |r| actionable?(r) }.sort_by { |r| rank_key(r) }
623
+ dispatchable = ranked.select { |r| %w[defer research].include?(r[:disposition]) }
624
+ human_only = ranked.select { |r| %w[drive triage].include?(r[:disposition]) }
625
+ nbt = ranked.find { |r| r[:disposition] == "drive" }
626
+ {
627
+ generated_for: "auto-mode",
628
+ scope: scope_label,
629
+ next_big_thing: nbt && nbt[:id],
630
+ dispatchable_queue: dispatchable.each_with_index.map do |r, i|
631
+ { id: r[:id], scope: r[:scope], disposition: r[:disposition], type: r[:type],
632
+ value: r[:value].to_s, effort: r[:effort].to_s, flags: r[:flags], rank: i + 1 }
633
+ end,
634
+ human_only: human_only.map { |r| r[:id] },
635
+ }
636
+ end
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # CLI
640
+ # ---------------------------------------------------------------------------
641
+
642
+ def main(argv)
643
+ json = argv.delete("--json")
644
+ data = argv.delete("--data")
645
+ mode = argv.shift || "continue"
646
+ slug = argv.shift
647
+
648
+ raw, done_ids, referenced = load_all
649
+ records = raw.map { |r| classify(r, done_ids, referenced) }
650
+
651
+ if data
652
+ payload = mode == "project" ? render_data_project(records, slug) : render_data_global(records)
653
+ puts JSON.pretty_generate(payload)
654
+ return 0
655
+ end
656
+
657
+ if json
658
+ subset = mode == "project" ? records.select { |r| r[:scope] == "project:#{slug}" } : records
659
+ label = mode == "project" ? "project:#{slug}" : "all"
660
+ payload = render_json(subset, label)
661
+ # Run the scoped store check on load, mirroring the --data board path:
662
+ # project board -> the slug; global/continue board -> :global. The `all`
663
+ # manifest spans every store, so its store-health probe is left to the
664
+ # per-scope boards (keeping the all-scopes auto-mode contract stable).
665
+ payload[:store_health] = store_health(slug) if mode == "project"
666
+ payload[:store_health] = store_health(:global) if mode == "continue"
667
+ puts JSON.pretty_generate(payload)
668
+ return 0
669
+ end
670
+
671
+ case mode
672
+ when "continue" then print render_continue(records)
673
+ when "project"
674
+ if slug.nil? || slug.empty?
675
+ warn "usage: dashboard.rb project <slug>"
676
+ return 2
677
+ end
678
+ print render_project(records, slug)
679
+ when "all" then print render_all(records)
680
+ else
681
+ warn "unknown mode: #{mode} (use continue|project <slug>|all)"
682
+ return 2
683
+ end
684
+ 0
685
+ end
686
+
687
+ exit(main(ARGV)) if $PROGRAM_NAME == __FILE__