@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.18",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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__
@@ -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
- # Outputs hook JSON for the continue hook. Exits silently if nothing to show.
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 "date"
8
- require "yaml"
9
+ require "open3"
9
10
 
10
- index_path, store_root, mode = ARGV
11
- exit 0 unless index_path && store_root && mode
11
+ index_path, _store_root, _mode = ARGV
12
+ exit 0 unless index_path && File.exist?(index_path)
12
13
 
13
- # --- Parse INDEX.md ---
14
+ dashboard = File.expand_path("dashboard.rb", __dir__)
15
+ exit 0 unless File.exist?(dashboard)
14
16
 
15
- lines = File.readlines(index_path)
16
- active = []
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
- lines.each do |line|
21
- if line.start_with?("## Active")
22
- section = :active
23
- next
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" => parts.join("\n")
28
+ "additionalContext" => context
128
29
  }
129
30
  }
130
31
  puts JSON.generate(payload)
@@ -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|
@@ -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.