@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.
- package/PLASTIC.md +163 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +37 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +37 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
- package/agents/plastic-planner.md +37 -0
- package/agents/plastic-spec-specialist.md +37 -0
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +1 -10
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +12 -0
- package/hooks/create-gate +3 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +52 -0
- package/hooks/qmd-search +8 -0
- package/package.json +2 -2
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1054 -628
- package/scripts/hook-auto-arm +51 -0
- package/scripts/hook-bash-gate +41 -0
- package/scripts/hook-code-gate +27 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-create-gate +59 -0
- package/scripts/hook-gate-check +47 -32
- package/scripts/hook-qmd-search +44 -0
- package/scripts/hook-session-start +106 -38
- package/scripts/install.rb +91 -529
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +404 -19
- package/scripts/lib/installer_core.rb +807 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/qmd_hook.rb +44 -0
- package/scripts/lib/qmd_sync.rb +209 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +226 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +92 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +121 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +164 -0
- package/scripts/validate-intent +54 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +1 -1
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +77 -7
- package/skills/auto/evals/evals.json +207 -0
- package/skills/auto/references/agent-architecture.md +135 -0
- package/skills/brainstorming/SKILL.md +9 -9
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +92 -82
- package/skills/continuing/evals/evals.json +136 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +74 -36
- package/skills/creating-intent/evals/evals.json +56 -0
- package/skills/creating-intent/references/lifecycle.md +76 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +40 -8
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +121 -0
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
- package/skills/doctor/SKILL.md +51 -4
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +4 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +5 -3
- package/skills/install/SKILL.md +69 -8
- package/skills/intent-curator/SKILL.md +3 -3
- package/skills/linking-intents/SKILL.md +16 -7
- package/skills/linking-intents/references/zettelkasten.md +38 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/managing-index/references/zettelkasten-linking.md +1 -1
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +2 -2
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -44
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +5 -5
- package/templates/agents.md +7 -7
- package/templates/outcome.md +13 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- 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__
|