@zalom/plastic 1.0.0-alpha.21 → 1.0.0-alpha.23
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 +170 -8
- package/scripts/hook-session-start +20 -0
- package/scripts/lib/boot_banner.rb +36 -0
- package/skills/continuing/SKILL.md +21 -41
- package/skills/continuing/evals/evals.json +9 -9
- package/skills/dashboard/SKILL.md +70 -48
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -87,6 +87,22 @@ def intent_dirs(store)
|
|
|
87
87
|
.sort
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Last-accessed timestamp: the timestamp of the last ISO8601 line in the
|
|
91
|
+
# deterministic savepoint ledger (intent 34); falls back to the created date.
|
|
92
|
+
ISO8601_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\b/
|
|
93
|
+
|
|
94
|
+
def last_accessed_at(dir, created)
|
|
95
|
+
sp = File.join(dir, "savepoint.md")
|
|
96
|
+
if File.exist?(sp)
|
|
97
|
+
File.readlines(sp).reverse_each do |line|
|
|
98
|
+
m = line.strip.match(ISO8601_RE)
|
|
99
|
+
return m[0] if m
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
return "#{created}T00:00:00Z" if created && !created.empty?
|
|
103
|
+
""
|
|
104
|
+
end
|
|
105
|
+
|
|
90
106
|
# Parse one intent directory into a raw record.
|
|
91
107
|
def parse_intent(store_info, dir_name, status_index)
|
|
92
108
|
dir = File.join(store_info[:store], dir_name)
|
|
@@ -124,6 +140,7 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
124
140
|
savepoint: has.("savepoint.md"),
|
|
125
141
|
checklist_partial: has.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
|
|
126
142
|
body_has_context: body.include?("## Context"),
|
|
143
|
+
last_accessed_at: last_accessed_at(dir, (fm["created"].to_s rescue "")),
|
|
127
144
|
}
|
|
128
145
|
end
|
|
129
146
|
|
|
@@ -155,7 +172,9 @@ def load_all
|
|
|
155
172
|
done_ids[[rec[:scope], rec[:id]]] = true if rec[:status] == "completed"
|
|
156
173
|
end
|
|
157
174
|
end
|
|
158
|
-
|
|
175
|
+
referenced = {}
|
|
176
|
+
all.each { |r| r[:sources].each { |s| referenced[[r[:scope], s]] = true } }
|
|
177
|
+
[all, done_ids, referenced]
|
|
159
178
|
end
|
|
160
179
|
|
|
161
180
|
# ---------------------------------------------------------------------------
|
|
@@ -200,19 +219,22 @@ end
|
|
|
200
219
|
# Value -> :high | :low (explicit frontmatter field wins).
|
|
201
220
|
# High is deliberately rare: an explicit stamp, or a human-authored root idea that has
|
|
202
221
|
# already spawned follow-on work (chain non-empty) — i.e. a strategic theme the user owns.
|
|
203
|
-
def value_of(rec)
|
|
222
|
+
def value_of(rec, referenced = {})
|
|
204
223
|
case rec[:value_field]
|
|
205
224
|
when "high" then return :high
|
|
206
225
|
when "low" then return :low
|
|
207
226
|
end
|
|
208
|
-
return :high if rec[:author] == "human" && root_intent?(rec[:id])
|
|
227
|
+
return :high if rec[:author] == "human" && root_intent?(rec[:id])
|
|
228
|
+
return :high unless rec[:chain].empty?
|
|
229
|
+
return :high if referenced[[rec[:scope], rec[:id]]]
|
|
209
230
|
:low
|
|
210
231
|
end
|
|
211
232
|
|
|
212
233
|
def flags_of(rec, done_ids)
|
|
213
234
|
flags = []
|
|
214
235
|
flags << "in-progress" if rec[:savepoint] || rec[:checklist_partial]
|
|
215
|
-
if
|
|
236
|
+
if rec[:status] == "future" && !rec[:sources].empty? &&
|
|
237
|
+
rec[:sources].all? { |s| done_ids[[rec[:scope], s]] }
|
|
216
238
|
flags << "unblocked"
|
|
217
239
|
end
|
|
218
240
|
age = stale_age(rec)
|
|
@@ -244,9 +266,9 @@ def disposition_of(type, quadrant)
|
|
|
244
266
|
end
|
|
245
267
|
end
|
|
246
268
|
|
|
247
|
-
def classify(rec, done_ids)
|
|
269
|
+
def classify(rec, done_ids, referenced = {})
|
|
248
270
|
type = intent_type(rec)
|
|
249
|
-
value = value_of(rec)
|
|
271
|
+
value = value_of(rec, referenced)
|
|
250
272
|
effort = effort_of(rec, type)
|
|
251
273
|
quadrant = QUADRANTS[[value, effort]]
|
|
252
274
|
disposition = disposition_of(type, quadrant)
|
|
@@ -280,6 +302,12 @@ LIFECYCLE_GLYPH = { "what" => "○", "why" => "◔", "how" => "◑", "exec" => "
|
|
|
280
302
|
DISPOSITION_GLYPH = { "drive" => "▸", "defer" => "⇢", "research" => "⊙", "triage" => "⚑" }.freeze
|
|
281
303
|
LEGEND = "legend ○ What ◔ Why ◑ How ◕ Exec ● Done │ ▸drive ⇢defer ⊙research ⚑triage │ ⇡unblocked"
|
|
282
304
|
|
|
305
|
+
# Markdown board glyphs (intent 37). Quadrant signatures + per-line bullets.
|
|
306
|
+
QUADRANT_BULLET = {
|
|
307
|
+
"quick_win" => "⚡", "next_big" => "★", "defer" => "→", "triage" => "⚑",
|
|
308
|
+
}.freeze
|
|
309
|
+
STATUS_GLYPH = { "active" => "◑", "completed" => "●", "future" => "○" }.freeze
|
|
310
|
+
|
|
283
311
|
# ---------------------------------------------------------------------------
|
|
284
312
|
# Rendering helpers
|
|
285
313
|
# ---------------------------------------------------------------------------
|
|
@@ -421,6 +449,133 @@ def render_all(records)
|
|
|
421
449
|
out.join("\n") + "\n"
|
|
422
450
|
end
|
|
423
451
|
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Markdown-board data payload (intent 37) — heavy side; the skill fills a
|
|
454
|
+
# Markdown template from this and presents it. Deterministic, golden-tested.
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
# Descending sort key for an ISO8601 / date string without reversing arrays.
|
|
458
|
+
def invert_ts(ts)
|
|
459
|
+
ts.to_s.ljust(20).chars.map { |c| 255 - c.ord }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def within_24h?(rec)
|
|
463
|
+
ts = rec[:last_accessed_at]
|
|
464
|
+
return false if ts.nil? || ts.empty?
|
|
465
|
+
d = (Date.parse(ts[0, 10]) rescue nil)
|
|
466
|
+
d && d >= (today - 1)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def worked_row(rec, project_scope)
|
|
470
|
+
glyph = STATUS_GLYPH[rec[:status]]
|
|
471
|
+
proj = rec[:scope] == "global" ? "global" : rec[:scope].sub("project:", "")
|
|
472
|
+
status_word = rec[:status] == "completed" ? "done" : rec[:status]
|
|
473
|
+
prefix = project_scope ? "" : "#{proj} | "
|
|
474
|
+
{
|
|
475
|
+
id: rec[:id], status: rec[:status], glyph: glyph,
|
|
476
|
+
last_accessed_at: rec[:last_accessed_at],
|
|
477
|
+
line: "#{glyph} #{prefix}#{status_word}: #{rec[:id]} #{rec[:intent]}".strip,
|
|
478
|
+
}
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def recently_worked(records, project_scope: nil)
|
|
482
|
+
pool = records.select do |r|
|
|
483
|
+
%w[active completed].include?(r[:status]) && within_24h?(r) &&
|
|
484
|
+
(project_scope.nil? || r[:scope] == project_scope)
|
|
485
|
+
end
|
|
486
|
+
ordered = pool.sort_by { |r| [r[:status] == "active" ? 0 : 1, invert_ts(r[:last_accessed_at])] }
|
|
487
|
+
ordered = ordered.first(5) if ordered.size > 15
|
|
488
|
+
ordered.map { |r| worked_row(r, project_scope) }
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def intent_line(rec, bullet)
|
|
492
|
+
note = rec[:status] == "active" ? " (#{rec[:lifecycle].to_s.capitalize})" : ""
|
|
493
|
+
{ id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
|
|
494
|
+
scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{rec[:intent]}#{note}".rstrip }
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def matrix_data(records)
|
|
498
|
+
cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
|
|
499
|
+
research = []
|
|
500
|
+
records.each do |r|
|
|
501
|
+
if %w[research exploration].include?(r[:type]) then research << r
|
|
502
|
+
else cells[r[:quadrant]] << r end
|
|
503
|
+
end
|
|
504
|
+
by_created_desc = ->(list) { list.sort_by { |r| invert_ts(r[:created]) } }
|
|
505
|
+
out = {}
|
|
506
|
+
cells.each { |q, list| out[q] = by_created_desc.call(list).map { |r| intent_line(r, QUADRANT_BULLET[q]) } }
|
|
507
|
+
out["research"] = by_created_desc.call(research).map { |r| intent_line(r, "🔬") }
|
|
508
|
+
out
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def short_description(scope)
|
|
512
|
+
return "" unless scope.start_with?("project:")
|
|
513
|
+
slug = scope.sub("project:", "")
|
|
514
|
+
agents = File.join(PLASTIC_HOME, "projects", slug, "AGENTS.md")
|
|
515
|
+
if File.exist?(agents)
|
|
516
|
+
File.readlines(agents).each do |l|
|
|
517
|
+
t = l.strip
|
|
518
|
+
next if t.empty? || t.start_with?("#", ">")
|
|
519
|
+
return t[0, 60]
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
slug
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def project_summaries(records)
|
|
526
|
+
scopes = records.map { |r| r[:scope] }.select { |s| s.start_with?("project:") }.uniq
|
|
527
|
+
rows = scopes.map do |scope|
|
|
528
|
+
scoped = records.select { |r| r[:scope] == scope }
|
|
529
|
+
{
|
|
530
|
+
slug: scope.sub("project:", ""),
|
|
531
|
+
description: short_description(scope),
|
|
532
|
+
active: scoped.count { |r| r[:status] == "active" },
|
|
533
|
+
done: scoped.count { |r| r[:status] == "completed" },
|
|
534
|
+
future: scoped.count { |r| r[:status] == "future" },
|
|
535
|
+
last_accessed_at: scoped.map { |r| r[:last_accessed_at] }.reject(&:empty?).max.to_s,
|
|
536
|
+
}
|
|
537
|
+
end
|
|
538
|
+
rows.sort_by { |r| invert_ts(r[:last_accessed_at]) }.first(5)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def counts_of(records)
|
|
542
|
+
{ active: records.count { |r| r[:status] == "active" },
|
|
543
|
+
done: records.count { |r| r[:status] == "completed" },
|
|
544
|
+
future: records.count { |r| r[:status] == "future" } }
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def render_data_global(records)
|
|
548
|
+
global = records.select { |r| r[:scope] == "global" }
|
|
549
|
+
matrix_pool = global.select { |r| actionable?(r) && r[:status] != "active" }
|
|
550
|
+
projs = project_summaries(records)
|
|
551
|
+
{ mode: "global", date: today.to_s,
|
|
552
|
+
recently_worked: recently_worked(records),
|
|
553
|
+
matrix: matrix_data(matrix_pool),
|
|
554
|
+
counts: counts_of(global),
|
|
555
|
+
projects: projs,
|
|
556
|
+
project_totals: {
|
|
557
|
+
active: projs.sum { |p| p[:active] }, done: projs.sum { |p| p[:done] },
|
|
558
|
+
future: projs.sum { |p| p[:future] }
|
|
559
|
+
} }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def render_data_project(records, slug)
|
|
563
|
+
scope = "project:#{slug}"
|
|
564
|
+
scoped = records.select { |r| r[:scope] == scope }
|
|
565
|
+
matrix_pool = scoped.select { |r| r[:status] == "future" }
|
|
566
|
+
{ mode: "project", date: today.to_s, slug: slug,
|
|
567
|
+
description: short_description(scope),
|
|
568
|
+
recently_worked: recently_worked(records, project_scope: scope),
|
|
569
|
+
matrix: matrix_data(matrix_pool),
|
|
570
|
+
counts: counts_of(scoped),
|
|
571
|
+
active: scoped.select { |r| r[:status] == "active" }
|
|
572
|
+
.sort_by { |r| invert_ts(r[:last_accessed_at]) }
|
|
573
|
+
.map { |r| intent_line(r, STATUS_GLYPH["active"]) },
|
|
574
|
+
future: scoped.select { |r| r[:status] == "future" }
|
|
575
|
+
.sort_by { |r| invert_ts(r[:created]) }
|
|
576
|
+
.map { |r| intent_line(r, STATUS_GLYPH["future"]) } }
|
|
577
|
+
end
|
|
578
|
+
|
|
424
579
|
# ---------------------------------------------------------------------------
|
|
425
580
|
# JSON renderer (agent / auto-mode contract)
|
|
426
581
|
# ---------------------------------------------------------------------------
|
|
@@ -448,11 +603,18 @@ end
|
|
|
448
603
|
|
|
449
604
|
def main(argv)
|
|
450
605
|
json = argv.delete("--json")
|
|
606
|
+
data = argv.delete("--data")
|
|
451
607
|
mode = argv.shift || "continue"
|
|
452
608
|
slug = argv.shift
|
|
453
609
|
|
|
454
|
-
raw, done_ids = load_all
|
|
455
|
-
records = raw.map { |r| classify(r, done_ids) }
|
|
610
|
+
raw, done_ids, referenced = load_all
|
|
611
|
+
records = raw.map { |r| classify(r, done_ids, referenced) }
|
|
612
|
+
|
|
613
|
+
if data
|
|
614
|
+
payload = mode == "project" ? render_data_project(records, slug) : render_data_global(records)
|
|
615
|
+
puts JSON.pretty_generate(payload)
|
|
616
|
+
return 0
|
|
617
|
+
end
|
|
456
618
|
|
|
457
619
|
if json
|
|
458
620
|
subset = mode == "project" ? records.select { |r| r[:scope] == "project:#{slug}" } : records
|
|
@@ -7,6 +7,8 @@ require "json"
|
|
|
7
7
|
require "date"
|
|
8
8
|
require "yaml"
|
|
9
9
|
require_relative "lib/bridge"
|
|
10
|
+
require_relative "lib/boot_banner"
|
|
11
|
+
require_relative "doctor"
|
|
10
12
|
|
|
11
13
|
index_path, store_root, mode, plugin_root = ARGV
|
|
12
14
|
exit 0 unless index_path && store_root && mode
|
|
@@ -172,6 +174,18 @@ if File.exist?(cache_file)
|
|
|
172
174
|
end
|
|
173
175
|
end
|
|
174
176
|
|
|
177
|
+
# --- Run core health in-process (intent 36a) ---
|
|
178
|
+
# Reuse Doctor's own --core checks so there is one source of truth for "core
|
|
179
|
+
# health" (no second process spawn, no duplicated check list). Never blocks the
|
|
180
|
+
# session: any failure or exception degrades to a banner and we continue.
|
|
181
|
+
|
|
182
|
+
core_health = begin
|
|
183
|
+
Doctor.new(plastic_home: store_root).run_core_checks("claude")
|
|
184
|
+
rescue
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
core_banner = BootBanner.render(health: core_health, version: current_version)
|
|
188
|
+
|
|
175
189
|
# --- Assemble session context ---
|
|
176
190
|
|
|
177
191
|
all_active = active + project_active
|
|
@@ -184,6 +198,12 @@ all_future = future + project_future
|
|
|
184
198
|
|
|
185
199
|
parts = []
|
|
186
200
|
|
|
201
|
+
# Boot banner first: every session start surfaces that Plastic loaded, with the
|
|
202
|
+
# version (clean) or the first failing core check (degraded). Owned by the hook
|
|
203
|
+
# so it runs by construction — the plastic-continuing skill no longer does this.
|
|
204
|
+
parts << core_banner
|
|
205
|
+
parts << ""
|
|
206
|
+
|
|
187
207
|
if plastic_md
|
|
188
208
|
# Conventions always loaded first
|
|
189
209
|
parts << plastic_md
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renders the SessionStart boot banner from a core-health result (intent 36a).
|
|
5
|
+
#
|
|
6
|
+
# Pure and dependency-injected: it takes the health hash (the return value of
|
|
7
|
+
# Doctor#run_core_checks) and the version string, and returns a single banner
|
|
8
|
+
# line. No I/O, no globals, no ENV — so it is unit-testable in isolation while
|
|
9
|
+
# the hook feeds it real data.
|
|
10
|
+
module BootBanner
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# health: the Hash returned by Doctor#run_core_checks, or nil if the check
|
|
14
|
+
# itself raised (degraded to an error banner).
|
|
15
|
+
# version: the installed Plastic version string, or nil.
|
|
16
|
+
def render(health:, version:)
|
|
17
|
+
return "Plastic Core: health check error — run /plastic-doctor" if health.nil?
|
|
18
|
+
|
|
19
|
+
if health[:status] == "pass"
|
|
20
|
+
"Plastic Core loaded — v#{version || "unknown"}"
|
|
21
|
+
else
|
|
22
|
+
bad = first_problem(health[:checks])
|
|
23
|
+
if bad
|
|
24
|
+
"Plastic Core loaded with issues — #{bad[:name]}: #{bad[:message]} — run /plastic-doctor"
|
|
25
|
+
else
|
|
26
|
+
"Plastic Core loaded with issues — run /plastic-doctor"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# First failing check, else first warning, else nil.
|
|
32
|
+
def first_problem(checks)
|
|
33
|
+
checks = checks || []
|
|
34
|
+
checks.find { |c| c[:status] == "fail" } || checks.find { |c| c[:status] == "warn" }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -1,64 +1,44 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plastic-continuing
|
|
3
|
-
description: Use when the user says "continue", "resume", or "pick up where we left off", or when starting a new session.
|
|
3
|
+
description: Use when the user says "continue", "resume", or "pick up where we left off", or when starting a new session. Continues work with the latest project context: lands on the right dashboard, then presents choices. Boot (health check, core context, version, statusline) is owned by the SessionStart hook, not this skill. Does not drive work autonomously (that is plastic-auto).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Continuing
|
|
7
7
|
|
|
8
|
-
`plastic-continuing`
|
|
9
|
-
then stops. It does NOT execute work autonomously (that is `plastic-auto`) and does
|
|
10
|
-
the dashboard itself (it only invokes it).
|
|
8
|
+
`plastic-continuing` continues work. It presents the latest state via the dashboard and offers
|
|
9
|
+
choices, then stops. It does NOT execute work autonomously (that is `plastic-auto`) and does
|
|
10
|
+
NOT render the dashboard itself (it only invokes it).
|
|
11
|
+
|
|
12
|
+
**Boot is not this skill's job.** The `hook-session-start` hook already runs by construction on
|
|
13
|
+
every session start: it runs the core health check (`doctor --core`), primes `PLASTIC.md` +
|
|
14
|
+
store/project state, and prints the `Plastic Core loaded — v{version}` banner. The
|
|
15
|
+
`plastic-statusline` hook sets the statusline. So by the time this skill runs, core is loaded
|
|
16
|
+
and healthy (or the banner already warned otherwise). This skill picks up from there and
|
|
17
|
+
continues work. This is the seam future continue-flags build on (see [[39]]).
|
|
11
18
|
|
|
12
19
|
## When to Use
|
|
13
20
|
- UserPromptSubmit hook detects "continue" (automatic)
|
|
14
21
|
- User says "continue", "resume", or "pick up where we left off"
|
|
15
|
-
- Starting a new session with
|
|
22
|
+
- Starting a new session and you want to resume work with the latest context
|
|
16
23
|
|
|
17
24
|
## Determine Store
|
|
18
25
|
|
|
19
26
|
1. **Global store** — `~/.plastic/INDEX.md` exists → global mode.
|
|
20
27
|
2. **Local store** — a project store under `~/.plastic/projects/{slug}/` whose registered
|
|
21
28
|
path (in `~/.plastic/projects.yml`) matches the current working directory → project mode.
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
The SessionStart hook already detects this; here you only need the slug to scope the
|
|
30
|
+
dashboard.
|
|
24
31
|
3. If neither exists → announce "No Plastic store found. Run /plastic-install."
|
|
25
32
|
|
|
26
|
-
##
|
|
27
|
-
|
|
28
|
-
### 1. Core doctor (health first)
|
|
29
|
-
Run the fast runtime-liveness check synchronously (it returns in well under a second):
|
|
33
|
+
## Continue (present the dashboard)
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
Land on the Markdown board via the `plastic-dashboard` skill. Rendering belongs there, not
|
|
36
|
+
here — run the data payload and fill + present the matching template:
|
|
37
|
+
- Project loaded → `ruby ~/.plastic/scripts/dashboard.rb project <slug> --data`
|
|
38
|
+
- Otherwise → `ruby ~/.plastic/scripts/dashboard.rb continue --data`
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- Otherwise → `Plastic core: issues` followed by the failing checks (name + message).
|
|
38
|
-
|
|
39
|
-
This runs first so a broken runtime (missing hooks, scripts, core files) surfaces before any
|
|
40
|
-
state is loaded on top of it. For a full diagnosis, point the user at `/plastic-doctor`.
|
|
41
|
-
|
|
42
|
-
### 2. Load core (context + state)
|
|
43
|
-
- Prime `PLASTIC.md` and the harness docs so the conventions are in mind.
|
|
44
|
-
- Load live state:
|
|
45
|
-
- Read the active store `INDEX.md` (`## Active`, `## Future`).
|
|
46
|
-
- Read `~/.plastic/projects.yml`.
|
|
47
|
-
- **Detect the current project** by matching CWD against registered project paths.
|
|
48
|
-
- If in a project: load that project's `INDEX.md`; load the governing intent (from
|
|
49
|
-
`parent` in projects.yml) and the tactical intents from
|
|
50
|
-
`~/.plastic/projects/{slug}/store/`.
|
|
51
|
-
|
|
52
|
-
### 3. Version + statusline
|
|
53
|
-
- Print the current Plastic version (from `~/.plastic/VERSION`).
|
|
54
|
-
- Set the statusline.
|
|
55
|
-
|
|
56
|
-
### 4. Dashboard
|
|
57
|
-
Invoke the dashboard. Rendering belongs to the dashboard skill, not here — only invoke:
|
|
58
|
-
- Project loaded → `ruby ~/.plastic/scripts/dashboard.rb project <slug>`
|
|
59
|
-
- Otherwise → `ruby ~/.plastic/scripts/dashboard.rb continue`
|
|
60
|
-
|
|
61
|
-
Show its output verbatim. See `plastic-dashboard` for how to read the matrix.
|
|
40
|
+
Fill the matching template from this skill's `templates/` and **present the filled Markdown
|
|
41
|
+
in your reply** (every time). See `plastic-dashboard` for the fill rules and entry flow.
|
|
62
42
|
|
|
63
43
|
### Then stop
|
|
64
44
|
Present "here is the state, what next?" and wait. Offer active intents first, then future
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"skill_name": "plastic-continuing",
|
|
3
|
-
"notes": "Intent 36. Scopes: description triggering (1-8) and behavior/convention compliance of the
|
|
3
|
+
"notes": "Intent 36, updated for 36a (boot moved into hook-session-start; the skill is now work-continuation only). Scopes: description triggering (1-8) and behavior/convention compliance of the work-continuation skill (9-14). Triggering assertions follow the plastic-auto eval style (one subagent router per case). Behavior assertions are convention checks against the SKILL.md.",
|
|
4
4
|
"results": {
|
|
5
5
|
"triggering": { "cases": 8, "passed": 8, "run": "2026-06-16, one subagent per case" },
|
|
6
6
|
"behavior": { "cases": 6, "passed": 6, "evidence": "convention checks against skills/continuing/SKILL.md after the intent-36 rewrite; doctor --core verified at 0.07s with 9 liveness checks and full doctor unchanged at 30 checks" }
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
{
|
|
37
37
|
"id": 4, "scope": "triggering", "set": "train",
|
|
38
38
|
"prompt": "boot plastic and show me where things stand",
|
|
39
|
-
"expected_output": "Activates plastic-continuing (
|
|
39
|
+
"expected_output": "Activates plastic-continuing (continuing work + presenting state is the skill's purpose; boot itself is hook-owned).",
|
|
40
40
|
"files": [],
|
|
41
41
|
"assertions": [
|
|
42
42
|
{ "type": "code", "check": "router CHOICE == plastic-continuing", "observed": "plastic-continuing", "result": "pass" }
|
|
@@ -80,20 +80,20 @@
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"id": 9, "scope": "behavior", "set": "train",
|
|
83
|
-
"prompt": "Does the skill
|
|
84
|
-
"expected_output": "SKILL.md
|
|
83
|
+
"prompt": "Does the skill defer boot to the SessionStart hook rather than running it itself?",
|
|
84
|
+
"expected_output": "SKILL.md states boot (health check, core context, version banner, statusline) is owned by hook-session-start / plastic-statusline, not this skill.",
|
|
85
85
|
"files": ["skills/continuing/SKILL.md"],
|
|
86
86
|
"assertions": [
|
|
87
|
-
{ "type": "convention", "check": "boot
|
|
87
|
+
{ "type": "convention", "check": "SKILL.md says boot is owned by the SessionStart hook and the skill does not run it", "observed": "'Boot is not this skill's job.' paragraph naming hook-session-start and plastic-statusline", "result": "pass" }
|
|
88
88
|
]
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"id": 10, "scope": "behavior", "set": "train",
|
|
92
|
-
"prompt": "Does
|
|
93
|
-
"expected_output": "
|
|
92
|
+
"prompt": "Does the skill avoid running doctor / setting the statusline itself?",
|
|
93
|
+
"expected_output": "SKILL.md does NOT instruct running doctor.rb --core or setting the statusline; it attributes those to the hooks.",
|
|
94
94
|
"files": ["skills/continuing/SKILL.md"],
|
|
95
95
|
"assertions": [
|
|
96
|
-
{ "type": "convention", "check": "
|
|
96
|
+
{ "type": "convention", "check": "no in-skill boot step invokes doctor.rb --core or sets the statusline; both are attributed to hooks", "observed": "doctor --core and statusline mentioned only as hook-owned in the 'Boot is not this skill's job' note", "result": "pass" }
|
|
97
97
|
]
|
|
98
98
|
},
|
|
99
99
|
{
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"expected_output": "Project loaded -> `dashboard.rb project <slug>`; otherwise -> `dashboard.rb continue`. Skill only invokes, does not render.",
|
|
103
103
|
"files": ["skills/continuing/SKILL.md"],
|
|
104
104
|
"assertions": [
|
|
105
|
-
{ "type": "convention", "check": "both dashboard invocations present and gated on project detection", "observed": "'dashboard.rb project <slug>' and 'dashboard.rb continue' in '
|
|
105
|
+
{ "type": "convention", "check": "both dashboard invocations present and gated on project detection", "observed": "'dashboard.rb project <slug>' and 'dashboard.rb continue' in the 'Continue (present the dashboard)' section", "result": "pass" }
|
|
106
106
|
]
|
|
107
107
|
},
|
|
108
108
|
{
|
|
@@ -1,65 +1,78 @@
|
|
|
1
1
|
---
|
|
2
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.
|
|
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 as Markdown across the global store and all projects, and emits a machine-readable queue that auto mode consumes.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Dashboard — Plastic Work Cockpit
|
|
7
7
|
|
|
8
|
-
A deterministic
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
A deterministic overview of the intent store(s). It answers three questions at a glance:
|
|
9
|
+
**where we are** (recently worked), **where we go next** (a Value×Effort matrix), and
|
|
10
|
+
**how to conduct it** (a disposition per intent). The human-facing surface is **Markdown**,
|
|
11
|
+
because the user's UI renders Markdown natively but collapses raw tool-call stdout.
|
|
12
12
|
|
|
13
|
-
The script does the
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
The script does all the data work. The agent fills a Markdown template from the script's
|
|
14
|
+
payload with near-zero reasoning and **presents the filled board in its reply**. Same store
|
|
15
|
+
state → byte-identical payload, regardless of model. Do NOT hand-summarize intents.
|
|
16
16
|
|
|
17
17
|
## When to Use
|
|
18
18
|
|
|
19
19
|
- User invokes `/plastic-dashboard`
|
|
20
20
|
- User asks "where are we", "what's next", "what should I work on", "show me the intents"
|
|
21
|
-
- `plastic-continuing`
|
|
21
|
+
- `plastic-continuing` lands on the board on resume
|
|
22
22
|
- `plastic-auto` reads `--json` to choose the next dispatchable intent
|
|
23
23
|
|
|
24
|
-
## Procedure
|
|
24
|
+
## Procedure (the Markdown board — default human surface)
|
|
25
25
|
|
|
26
|
-
### Step 1 —
|
|
26
|
+
### Step 1 — Get the data payload
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
ruby ~/.plastic/scripts/dashboard.rb [continue|project <slug
|
|
29
|
+
ruby ~/.plastic/scripts/dashboard.rb [continue|project <slug>] --data
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
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 |
|
|
32
|
+
- `continue` (default) → the **global** board payload (`mode: "global"`).
|
|
33
|
+
- `project <slug>` → that **project** board payload (`mode: "project"`).
|
|
38
34
|
|
|
39
|
-
The
|
|
40
|
-
|
|
35
|
+
The payload is read-only JSON. Global-board fields: `date`, `recently_worked`, `matrix`
|
|
36
|
+
(`quick_win`/`next_big`/`defer`/`triage`/`research`, each a list of `{line, bullet, ...}`),
|
|
37
|
+
`counts`, `projects`, `project_totals`. Project-board fields: `slug`, `description`,
|
|
38
|
+
`recently_worked`, `matrix`, `counts`, `active`, `future`.
|
|
41
39
|
|
|
42
|
-
### Step 2 —
|
|
40
|
+
### Step 2 — Fill the matching template
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
Templates live in this skill's `templates/` directory:
|
|
43
|
+
`~/.claude/skills/plastic-dashboard/templates/dashboard-global.md` and
|
|
44
|
+
`dashboard-project.md`.
|
|
45
|
+
|
|
46
|
+
Fill mechanically — no rewriting, no re-sorting:
|
|
47
|
+
- `{{a.b.count}}` → the integer (e.g. `matrix.quick_win.count` = that list's length).
|
|
48
|
+
- `{{...lines}}` → join the list's `.line` strings with **real newlines** (one per line).
|
|
49
|
+
These lines are already glyph-led (the glyph is the bullet — never add `-`, never emit
|
|
50
|
+
`<br>`). If a matrix quadrant list is empty, render `_(none)_`.
|
|
51
|
+
- `projects.lines` → one line per project:
|
|
52
|
+
`- **{slug}** — {description} · active {active} · done {done} · future {future} · last accessed {last_accessed_at[0,10]}`.
|
|
53
|
+
- Scalars (`{{date}}`, `{{slug}}`, `{{description}}`) → substitute verbatim.
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
`⊙ research` (research/explore agent), `⚑ triage` (human review / maybe abandon).
|
|
53
|
-
Flags: `⇡ unblocked` (a dependency just completed), `(Nd)` stale age.
|
|
55
|
+
### Step 3 — Present it (mandatory, every invocation)
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
**Paste the filled Markdown into your reply.** This is non-optional: the board only reaches
|
|
58
|
+
the user when it is in the chat reply, not in tool-call stdout. Never describe the board
|
|
59
|
+
instead of showing it.
|
|
56
60
|
|
|
57
|
-
|
|
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
|
+
### Step 4 — Entry flow (the board is the menu)
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
The board lists everything; the user navigates by free prose (no capped picker):
|
|
64
|
+
- On the **global** board, the user replies with an **intent id** (work it), a **project
|
|
65
|
+
name** (re-run `project <slug> --data` and present that board), or **"new"** (start a new
|
|
66
|
+
intent in global via `plastic-creating-intent`).
|
|
67
|
+
- On a **project** board, the user replies with an **intent id**, or **"global"** to return.
|
|
68
|
+
|
|
69
|
+
## Auto-mode contract (`--json`)
|
|
70
|
+
|
|
71
|
+
`plastic-auto` consumes a separate machine-readable manifest (unchanged):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ruby ~/.plastic/scripts/dashboard.rb [continue|project <slug>|all] --json
|
|
75
|
+
```
|
|
63
76
|
|
|
64
77
|
```json
|
|
65
78
|
{ "generated_for": "auto-mode", "scope": "<scope|all>",
|
|
@@ -68,25 +81,34 @@ Flags: `⇡ unblocked` (a dependency just completed), `(Nd)` stale age.
|
|
|
68
81
|
"human_only": ["<id>", "..."] }
|
|
69
82
|
```
|
|
70
83
|
|
|
84
|
+
Work `dispatchable_queue` in `rank` order (`defer`/`research`); leave `human_only` for the user.
|
|
85
|
+
|
|
86
|
+
## Text modes (terminal / legacy)
|
|
87
|
+
|
|
88
|
+
`dashboard.rb [continue|project <slug>|all]` with no flag still prints the ASCII cockpit for
|
|
89
|
+
a raw terminal. The Markdown board (`--data` + template) is the surface for the chat UI.
|
|
90
|
+
|
|
71
91
|
## How classification works (deterministic)
|
|
72
92
|
|
|
73
93
|
- **Effort** — small for `research`/`exploration`/`bugfix`, for already-scoped intents
|
|
74
94
|
(plan/checklist exists), or deep refinement branches; big otherwise.
|
|
75
|
-
- **Value
|
|
76
|
-
|
|
77
|
-
- **
|
|
78
|
-
|
|
95
|
+
- **Value → high** when any of: explicit `value: high`; a human-authored **root** intent; an
|
|
96
|
+
intent with a non-empty `chain`; or an intent that is a `source` of ≥1 other intent. Else low.
|
|
97
|
+
- **Flags** — `unblocked` only when a **future** intent has **all** its `sources` done;
|
|
98
|
+
`stale` only on future intents past the staleness threshold. Both kept low-noise by design.
|
|
99
|
+
- **Override** — a `value: high|low` frontmatter field always wins (pre-stamped data, never
|
|
100
|
+
model judgment at render time).
|
|
79
101
|
|
|
80
102
|
## Eval
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`test/fixtures/dashboard/`. See `test/dashboard_test.rb`.
|
|
85
|
-
|
|
104
|
+
The eval is the payload + golden snapshots: run the engine against the fixture store and
|
|
105
|
+
assert the `--data` payload shape/sorting/classification and the byte-identical `--json` +
|
|
106
|
+
text goldens in `test/fixtures/dashboard/`. See `test/dashboard_test.rb`. Drift without an
|
|
107
|
+
intentional change means the skill is broken.
|
|
86
108
|
|
|
87
109
|
## Notes
|
|
88
110
|
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
-
|
|
92
|
-
-
|
|
111
|
+
- Quadrant lists are **not** Markdown `-` bullets — the glyph is the bullet (the boards are
|
|
112
|
+
UI-only and may evolve, so they need not be valid Markdown lists). Never emit `<br>`.
|
|
113
|
+
- Clusters (Zettelkasten grouping in INDEX.md) are intentionally not rendered.
|
|
114
|
+
- Additive: changes no core lifecycle, gate, or cycle logic.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# 🧩 Plastic · Global Board — {{date}}
|
|
2
|
+
|
|
3
|
+
**Recently worked** · last 24h
|
|
4
|
+
{{recently_worked.lines}}
|
|
5
|
+
|
|
6
|
+
## Where we go next · Value × Effort *(global intents only)*
|
|
7
|
+
|
|
8
|
+
| | Small effort | Big effort |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| **High value** | ⚡ **Quick win** · {{matrix.quick_win.count}} | ★ **Next big thing** · {{matrix.next_big.count}} |
|
|
11
|
+
| **Low value** | → **Defer (agent)** · {{matrix.defer.count}} | ⚑ **Triage** · {{matrix.triage.count}} |
|
|
12
|
+
|
|
13
|
+
**⚡ Quick win** — small effort, high value
|
|
14
|
+
{{matrix.quick_win.lines}}
|
|
15
|
+
|
|
16
|
+
**★ Next big thing** — big effort, high value
|
|
17
|
+
{{matrix.next_big.lines}}
|
|
18
|
+
|
|
19
|
+
**→ Defer (agent)** — small effort, low value
|
|
20
|
+
{{matrix.defer.lines}}
|
|
21
|
+
|
|
22
|
+
**⚑ Triage** — big effort, low value
|
|
23
|
+
{{matrix.triage.lines}}
|
|
24
|
+
|
|
25
|
+
🔬 **Research → agent**
|
|
26
|
+
{{matrix.research.lines}}
|
|
27
|
+
|
|
28
|
+
## Projects · active {{project_totals.active}} · done {{project_totals.done}} · future {{project_totals.future}}
|
|
29
|
+
{{projects.lines}}
|
|
30
|
+
|
|
31
|
+
**What would you like to work on next?** (type an **intent id**, a **project name**, or anything **new** you'd like to start)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 📦 {{slug}} · Project Board — {{date}}
|
|
2
|
+
|
|
3
|
+
{{description}}
|
|
4
|
+
|
|
5
|
+
**Recently worked** · last 24h
|
|
6
|
+
{{recently_worked.lines}}
|
|
7
|
+
|
|
8
|
+
## Where we go next · Value × Effort
|
|
9
|
+
|
|
10
|
+
| | Small effort | Big effort |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| **High value** | ⚡ **Quick win** · {{matrix.quick_win.count}} | ★ **Next big thing** · {{matrix.next_big.count}} |
|
|
13
|
+
| **Low value** | → **Defer (agent)** · {{matrix.defer.count}} | ⚑ **Triage** · {{matrix.triage.count}} |
|
|
14
|
+
|
|
15
|
+
**⚡ Quick win** — small effort, high value
|
|
16
|
+
{{matrix.quick_win.lines}}
|
|
17
|
+
|
|
18
|
+
**★ Next big thing** — big effort, high value
|
|
19
|
+
{{matrix.next_big.lines}}
|
|
20
|
+
|
|
21
|
+
**→ Defer (agent)** — small effort, low value
|
|
22
|
+
{{matrix.defer.lines}}
|
|
23
|
+
|
|
24
|
+
**⚑ Triage** — big effort, low value
|
|
25
|
+
{{matrix.triage.lines}}
|
|
26
|
+
|
|
27
|
+
🔬 **Research → agent**
|
|
28
|
+
{{matrix.research.lines}}
|
|
29
|
+
|
|
30
|
+
## Intents · active {{counts.active}} · done {{counts.done}} · future {{counts.future}}
|
|
31
|
+
|
|
32
|
+
**Active**
|
|
33
|
+
{{active.lines}}
|
|
34
|
+
|
|
35
|
+
**Future**
|
|
36
|
+
{{future.lines}}
|
|
37
|
+
|
|
38
|
+
**Legend** · ○ What ◔ Why ◑ How ◕ Exec ● Done · ⚡ quick win ★ big → defer ⚑ triage 🔬 research
|
|
39
|
+
|
|
40
|
+
**What would you like to work on next?** (type an **intent id**, or **global** to go back)
|