@zalom/plastic 1.0.0-alpha.16 → 1.0.0-alpha.18
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/package.json +1 -1
- package/scripts/dashboard.rb +480 -0
- package/scripts/hook-continue +15 -114
- package/scripts/install.rb +1 -0
- package/skills/auto/SKILL.md +12 -0
- package/skills/continuing/SKILL.md +12 -0
- package/skills/dashboard/SKILL.md +92 -0
package/package.json
CHANGED
|
@@ -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__
|
package/scripts/hook-continue
CHANGED
|
@@ -1,130 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# Usage: hook-continue <index_path> <store_root> <mode>
|
|
4
|
-
#
|
|
4
|
+
# Renders the dashboard `continue` cockpit as UserPromptSubmit additionalContext.
|
|
5
|
+
# Exits silently (no output) if the dashboard produces nothing — the bash caller
|
|
6
|
+
# then falls back to a minimal notice.
|
|
5
7
|
|
|
6
8
|
require "json"
|
|
7
|
-
require "
|
|
8
|
-
require "yaml"
|
|
9
|
+
require "open3"
|
|
9
10
|
|
|
10
|
-
index_path,
|
|
11
|
-
exit 0 unless index_path &&
|
|
11
|
+
index_path, _store_root, _mode = ARGV
|
|
12
|
+
exit 0 unless index_path && File.exist?(index_path)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
15
|
+
exit 0 unless File.exist?(dashboard)
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
future = []
|
|
18
|
-
section = nil
|
|
17
|
+
cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
|
|
18
|
+
exit 0 unless status.success? && !cockpit.strip.empty?
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
elsif line.start_with?("## Future")
|
|
25
|
-
section = :future
|
|
26
|
-
next
|
|
27
|
-
elsif line.start_with?("## ")
|
|
28
|
-
section = nil
|
|
29
|
-
next
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
next unless section && line.strip.start_with?("- [")
|
|
33
|
-
|
|
34
|
-
if section == :active
|
|
35
|
-
active << line.strip
|
|
36
|
-
elsif section == :future
|
|
37
|
-
future << line.strip
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# --- Detect stale future intents ---
|
|
42
|
-
|
|
43
|
-
stale = []
|
|
44
|
-
stale_days = 3
|
|
45
|
-
config_path = "#{store_root}/config.yml"
|
|
46
|
-
if File.exist?(config_path)
|
|
47
|
-
config = YAML.safe_load(File.read(config_path)) rescue {}
|
|
48
|
-
stale_days = config["stale_threshold_days"] || 3
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
future.each do |f|
|
|
52
|
-
if f =~ /store\/([\w-]+)\//
|
|
53
|
-
dir_name = $1
|
|
54
|
-
intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
|
|
55
|
-
next unless File.exist?(intent_file)
|
|
56
|
-
|
|
57
|
-
content = File.read(intent_file)
|
|
58
|
-
if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
|
|
59
|
-
age = (Date.today - Date.parse($1)).to_i
|
|
60
|
-
if age >= stale_days
|
|
61
|
-
name = f[/\[([^\]]+)\]/, 1] || "unknown"
|
|
62
|
-
stale << { name: name, age: age, entry: f }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# --- Detect current project ---
|
|
69
|
-
|
|
70
|
-
current_project = nil
|
|
71
|
-
if mode == "global"
|
|
72
|
-
projects_path = "#{store_root}/projects.yml"
|
|
73
|
-
if File.exist?(projects_path)
|
|
74
|
-
projects = YAML.safe_load(File.read(projects_path)) rescue {}
|
|
75
|
-
cwd = Dir.pwd
|
|
76
|
-
(projects["projects"] || {}).each do |slug, info|
|
|
77
|
-
project_path = File.expand_path(info["path"])
|
|
78
|
-
if cwd.start_with?(project_path)
|
|
79
|
-
current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
|
|
80
|
-
break
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# --- Build context ---
|
|
87
|
-
|
|
88
|
-
parts = []
|
|
89
|
-
parts << "PLASTIC CONTINUE — The user wants to resume work.\n"
|
|
90
|
-
|
|
91
|
-
if mode == "global" && current_project
|
|
92
|
-
parts << "Project: #{current_project["slug"]} (#{current_project["path"]})\n"
|
|
93
|
-
parts << "Governing intent: #{current_project["parent"]}\n" if current_project["parent"]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
if active.any?
|
|
97
|
-
parts << "## Active Intents (work on these first)\n"
|
|
98
|
-
active.each { |a| parts << a }
|
|
99
|
-
parts << ""
|
|
100
|
-
else
|
|
101
|
-
parts << "## No Active Intents\n"
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
if future.any?
|
|
105
|
-
parts << "## Future Intents (offer as next work)\n"
|
|
106
|
-
future.each { |f| parts << f }
|
|
107
|
-
parts << ""
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
if stale.any?
|
|
111
|
-
parts << "## Stale Future Intents\n"
|
|
112
|
-
parts << "These future intents have not been actioned. Ask the user what to do with each:\n"
|
|
113
|
-
stale.each do |s|
|
|
114
|
-
parts << "- #{s[:name]} (#{s[:age]} days old) — options: activate, abandon, or defer to agent (implement / research / ideate)"
|
|
115
|
-
end
|
|
116
|
-
parts << ""
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
parts << "Follow the plastic-continuing skill workflow.\n"
|
|
120
|
-
parts << "1. If active intents exist: read their state and resume\n"
|
|
121
|
-
parts << "2. If no active intents: present future intents as options\n"
|
|
122
|
-
parts << "3. If stale intents exist: surface them and ask user to decide"
|
|
20
|
+
context = cockpit.rstrip +
|
|
21
|
+
"\n\nFollow the plastic-continuing skill workflow to resume: " \
|
|
22
|
+
"if active intents exist, read their state and resume; otherwise offer the " \
|
|
23
|
+
"Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
|
|
123
24
|
|
|
124
25
|
payload = {
|
|
125
26
|
"hookSpecificOutput" => {
|
|
126
27
|
"hookEventName" => "UserPromptSubmit",
|
|
127
|
-
"additionalContext" =>
|
|
28
|
+
"additionalContext" => context
|
|
128
29
|
}
|
|
129
30
|
}
|
|
130
31
|
puts JSON.generate(payload)
|
package/scripts/install.rb
CHANGED
|
@@ -168,6 +168,7 @@ def distribute(mode)
|
|
|
168
168
|
"scripts/hook-auto-arm" => "scripts/hook-auto-arm",
|
|
169
169
|
"scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
|
|
170
170
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
171
|
+
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
core_files.each do |src, dest|
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -16,6 +16,18 @@ An active intent MUST exist in INDEX.md. If none exists, refuse: "No active inte
|
|
|
16
16
|
|
|
17
17
|
If multiple active intents exist, ask the user which one to deliver (this is the only question auto asks).
|
|
18
18
|
|
|
19
|
+
**Picking work when no intent is specified.** If the user says "auto" without naming an
|
|
20
|
+
intent and none is active, consult the dashboard's machine-readable queue to choose the
|
|
21
|
+
next dispatchable intent:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ruby ~/.plastic/scripts/dashboard.rb all --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Work `dispatchable_queue` in `rank` order (these are `defer`/`research` dispositions —
|
|
28
|
+
safe to deliver autonomously). Leave `human_only` and `next_big_thing` for the user — those
|
|
29
|
+
are `drive`/`triage` items the human should lead. See the `plastic-dashboard` skill.
|
|
30
|
+
|
|
19
31
|
## Arm the Lifecycle Gate (do this FIRST)
|
|
20
32
|
|
|
21
33
|
Immediately after selecting the intent — before any other work — arm auto mode. This
|
|
@@ -18,6 +18,18 @@ description: Use when the user says "continue" after a /clear, or when resuming
|
|
|
18
18
|
|
|
19
19
|
## Workflow
|
|
20
20
|
|
|
21
|
+
### 0. Render the dashboard (overview)
|
|
22
|
+
For the "where are we / what's next" overview, run the deterministic dashboard and show
|
|
23
|
+
its output verbatim instead of hand-summarizing intents:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ruby ~/.plastic/scripts/dashboard.rb continue
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This is the uniform, model-agnostic cockpit (active + last touched, then the Value×Effort
|
|
30
|
+
matrix). Then continue with the steps below to actually resume a specific intent. See the
|
|
31
|
+
`plastic-dashboard` skill for how to read the matrix.
|
|
32
|
+
|
|
21
33
|
### 1. Read INDEX.md
|
|
22
34
|
Read the INDEX.md from the active store. Extract intents under `## Active` and `## Future`.
|
|
23
35
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plastic-dashboard
|
|
3
|
+
description: Use when the user wants an overview of intents, asks "where are we", "what's next", "what should I work on", "show the dashboard", or invokes /plastic-dashboard. Renders a deterministic Value×Effort work cockpit across the global store and all projects, and emits a machine-readable queue that auto mode consumes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dashboard — Plastic Work Cockpit
|
|
7
|
+
|
|
8
|
+
A deterministic, template-driven overview of the intent store(s). It answers three
|
|
9
|
+
questions at a glance — **where we are** (active + last touched), **where we go next**
|
|
10
|
+
(a Value×Effort matrix), and **how to conduct it** (a disposition verb per intent) — and
|
|
11
|
+
emits a JSON manifest that `plastic-auto` reads to pick the next dispatchable intent.
|
|
12
|
+
|
|
13
|
+
The script does the rendering. The LLM is **never** in the rendering path: same store
|
|
14
|
+
state → byte-identical output, regardless of model. Do not hand-summarize intents when
|
|
15
|
+
this skill applies — run the script and show its output verbatim.
|
|
16
|
+
|
|
17
|
+
## When to Use
|
|
18
|
+
|
|
19
|
+
- User invokes `/plastic-dashboard`
|
|
20
|
+
- User asks "where are we", "what's next", "what should I work on", "show me the intents"
|
|
21
|
+
- `plastic-continuing` embeds the `continue` view on resume
|
|
22
|
+
- `plastic-auto` reads `--json` to choose the next dispatchable intent
|
|
23
|
+
|
|
24
|
+
## Procedure
|
|
25
|
+
|
|
26
|
+
### Step 1 — Run the script
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ruby ~/.plastic/scripts/dashboard.rb [continue|project <slug>|all] [--json]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Mode | Shows |
|
|
33
|
+
|------|-------|
|
|
34
|
+
| `continue` (default) | Cross-scope: active + last touched, then the Value×Effort matrix for all scopes |
|
|
35
|
+
| `project <slug>` | One project: active line + its Value×Effort matrix |
|
|
36
|
+
| `all` | Per-scope roll-up summary |
|
|
37
|
+
| `--json` | The auto-mode manifest (any mode); machine-readable, not for humans |
|
|
38
|
+
|
|
39
|
+
The script is **read-only**. Print its stdout verbatim — do not reformat, re-sort, or
|
|
40
|
+
re-summarize. That is what keeps the output uniform.
|
|
41
|
+
|
|
42
|
+
### Step 2 — Read the matrix
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
small effort big effort
|
|
46
|
+
high value QUICK WIN ★ NEXT BIG THING
|
|
47
|
+
low value DEFER → agent TRIAGE / question
|
|
48
|
+
(type=research/exploration → RESEARCH band, regardless of quadrant)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Disposition verbs: `▸ drive` (human leads), `⇢ defer` (agent knocks off),
|
|
52
|
+
`⊙ research` (research/explore agent), `⚑ triage` (human review / maybe abandon).
|
|
53
|
+
Flags: `⇡ unblocked` (a dependency just completed), `(Nd)` stale age.
|
|
54
|
+
|
|
55
|
+
### Step 3 — Act on it
|
|
56
|
+
|
|
57
|
+
- **★ Next big thing** and `▸ drive` / `⚑ triage` items → the human leads (brainstorm → plan → exec).
|
|
58
|
+
- `⇢ defer` and `⊙ research` items → dispatchable to agents.
|
|
59
|
+
- In auto mode, read `--json` and work `dispatchable_queue` in `rank` order; leave
|
|
60
|
+
`human_only` for the user.
|
|
61
|
+
|
|
62
|
+
## JSON contract
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "generated_for": "auto-mode", "scope": "<scope|all>",
|
|
66
|
+
"next_big_thing": "<id|null>",
|
|
67
|
+
"dispatchable_queue": [ {"id","scope","disposition","type","value","effort","flags","rank"} ],
|
|
68
|
+
"human_only": ["<id>", "..."] }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How classification works (deterministic)
|
|
72
|
+
|
|
73
|
+
- **Effort** — small for `research`/`exploration`/`bugfix`, for already-scoped intents
|
|
74
|
+
(plan/checklist exists), or deep refinement branches; big otherwise.
|
|
75
|
+
- **Value** — high only for an explicit `value: high` frontmatter field, or a
|
|
76
|
+
human-authored root intent that has spawned follow-on work (`chain` non-empty); low otherwise.
|
|
77
|
+
- **Override** — a `value: high|normal|low` field in an intent's frontmatter wins. This is
|
|
78
|
+
the only place model judgment enters, and only as pre-stamped data (never at render time).
|
|
79
|
+
|
|
80
|
+
## Eval
|
|
81
|
+
|
|
82
|
+
This skill's eval is **render the template**: run the engine against the test fixture
|
|
83
|
+
store and assert byte-identical text + JSON output against the golden snapshots in
|
|
84
|
+
`test/fixtures/dashboard/`. See `test/dashboard_test.rb`. If output drifts from the
|
|
85
|
+
golden files without an intentional template change, the skill is broken.
|
|
86
|
+
|
|
87
|
+
## Notes
|
|
88
|
+
|
|
89
|
+
- Clusters (Zettelkasten grouping in INDEX.md) are intentionally **not** rendered — they
|
|
90
|
+
are orthogonal to "what to work on next."
|
|
91
|
+
- Glyphs are monochrome Unicode (no emoji); they render in standard terminal emulators.
|
|
92
|
+
- This is additive: it changes no core lifecycle, gate, or cycle logic.
|