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