@zalom/plastic 1.0.0-alpha.15 → 1.0.0-alpha.17

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.
Files changed (40) hide show
  1. package/PLASTIC.md +18 -18
  2. package/README.md +13 -6
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/deprecations.yml +6 -5
  6. package/package.json +1 -1
  7. package/scripts/dashboard.rb +480 -0
  8. package/scripts/doctor.rb +37 -30
  9. package/scripts/hook-auto-arm +3 -3
  10. package/scripts/hook-continue +1 -1
  11. package/scripts/hook-gate-check +3 -3
  12. package/scripts/hook-session-start +1 -1
  13. package/scripts/install.rb +175 -119
  14. package/scripts/lib/bridge.rb +1 -1
  15. package/scripts/migrate-to-global +1 -1
  16. package/skills/_active-intent-gate.md +1 -1
  17. package/skills/auto/SKILL.md +18 -6
  18. package/skills/auto/evals/evals.json +17 -17
  19. package/skills/auto/references/agent-architecture.md +1 -1
  20. package/skills/brainstorming/SKILL.md +8 -8
  21. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  22. package/skills/continuing/SKILL.md +16 -4
  23. package/skills/creating-intent/SKILL.md +1 -1
  24. package/skills/creating-project/SKILL.md +4 -4
  25. package/skills/dashboard/SKILL.md +92 -0
  26. package/skills/doctor/SKILL.md +4 -4
  27. package/skills/evaluating-skills/SKILL.md +1 -1
  28. package/skills/executing-plan/SKILL.md +1 -1
  29. package/skills/install/SKILL.md +32 -8
  30. package/skills/intent-curator/SKILL.md +3 -3
  31. package/skills/linking-intents/SKILL.md +1 -1
  32. package/skills/managing-index/SKILL.md +1 -1
  33. package/skills/releasing/SKILL.md +3 -3
  34. package/skills/research/SKILL.md +2 -2
  35. package/skills/savepoint/SKILL.md +1 -1
  36. package/skills/uninstall/SKILL.md +39 -28
  37. package/skills/update/SKILL.md +2 -2
  38. package/skills/writing-instructions/SKILL.md +2 -2
  39. package/skills/writing-plans/SKILL.md +5 -5
  40. package/templates/agents.md +7 -7
package/PLASTIC.md CHANGED
@@ -61,10 +61,10 @@ The connection: an intent's `## Insights` feeds the Coordinator's Observe phase.
61
61
 
62
62
  | Stage | Section | Deliverable | Detail |
63
63
  |-------|---------|-------------|--------|
64
- | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic:creating-intent` |
65
- | **Why** | `## Context` + Decisions | `spec.md` | `plastic:brainstorming` |
66
- | **How** | Planning | `plan.md` + `actions/` + `checklist.md` | `plastic:writing-plans` |
67
- | **Exec** | Execution | `outcome.md` | `plastic:executing-plan` |
64
+ | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-creating-intent` |
65
+ | **Why** | `## Context` + Decisions | `spec.md` | `plastic-brainstorming` |
66
+ | **How** | Planning | `plan.md` + `actions/` + `checklist.md` | `plastic-writing-plans` |
67
+ | **Exec** | Execution | `outcome.md` | `plastic-executing-plan` |
68
68
 
69
69
  `## Insights` — append-only work log captured throughout ALL stages.
70
70
  For full lifecycle detail, the skills in the Detail column have references/.
@@ -136,7 +136,7 @@ A Zettelkasten structure note, not a table of contents. Clusters by meaning.
136
136
 
137
137
  Sections: `## Active`, `## Future`, `## Clusters`, `## Abandoned`, `## Completed`.
138
138
 
139
- For index maintenance, use `plastic:managing-index`.
139
+ For index maintenance, use `plastic-managing-index`.
140
140
 
141
141
  ## Rules for Skills
142
142
 
@@ -165,16 +165,16 @@ Detailed conventions live inside the skills that use them, not in this file.
165
165
 
166
166
  | Topic | Skill | References in skill |
167
167
  |-------|-------|-------------------|
168
- | Creating intents, lifecycle | `plastic:creating-intent` | lifecycle, wikilinks |
169
- | Brainstorming, spec writing | `plastic:brainstorming` | — |
170
- | Planning, actions | `plastic:writing-plans` | — |
171
- | Execution, delivery | `plastic:executing-plan` | — |
172
- | Autonomous delivery | `plastic:auto` | agent architecture |
173
- | Save/restore state | `plastic:savepoint`, `plastic:continuing` | context management |
174
- | Knowledge graph, linking | `plastic:linking-intents` | zettelkasten, wikilinks |
175
- | Projects, hubs | `plastic:creating-project` | hubs, project stores |
176
- | Index maintenance | `plastic:managing-index` | — |
177
- | Releases, deprecations | `plastic:releasing` | deprecation process |
178
- | Health diagnostics | `plastic:doctor` | gate enforcement, stuck detection |
179
- | Writing agent instructions | `plastic:writing-instructions` | agentskills.io spec |
180
- | Evaluating skills, evals | `plastic:evaluating-skills` | eval methodology, convention checks |
168
+ | Creating intents, lifecycle | `plastic-creating-intent` | lifecycle, wikilinks |
169
+ | Brainstorming, spec writing | `plastic-brainstorming` | — |
170
+ | Planning, actions | `plastic-writing-plans` | — |
171
+ | Execution, delivery | `plastic-executing-plan` | — |
172
+ | Autonomous delivery | `plastic-auto` | agent architecture |
173
+ | Save/restore state | `plastic-savepoint`, `plastic-continuing` | context management |
174
+ | Knowledge graph, linking | `plastic-linking-intents` | zettelkasten, wikilinks |
175
+ | Projects, hubs | `plastic-creating-project` | hubs, project stores |
176
+ | Index maintenance | `plastic-managing-index` | — |
177
+ | Releases, deprecations | `plastic-releasing` | deprecation process |
178
+ | Health diagnostics | `plastic-doctor` | gate enforcement, stuck detection |
179
+ | Writing agent instructions | `plastic-writing-instructions` | agentskills.io spec |
180
+ | Evaluating skills, evals | `plastic-evaluating-skills` | eval methodology, convention checks |
package/README.md CHANGED
@@ -38,6 +38,13 @@ npx @zalom/plastic --claude
38
38
  Replace `--claude` with `--codex` for Codex CLI, `--hermes` for Hermes, or
39
39
  `--all` for all supported agents.
40
40
 
41
+ Bun users can substitute `bunx` for `npx` (e.g. `bunx @zalom/plastic@alpha --claude`).
42
+ Bun is never required.
43
+
44
+ Skills install as flat, hyphen-namespaced personal skills (`plastic-doctor`,
45
+ `plastic-auto`, …) — invoke them with a hyphen. Plastic is **not** a Claude Code
46
+ plugin; re-running the installer auto-removes any legacy plugin registration.
47
+
41
48
  ### Updating
42
49
 
43
50
  From within your agent, say "update plastic" or run:
@@ -46,25 +53,25 @@ From within your agent, say "update plastic" or run:
46
53
  npx @zalom/plastic@alpha --claude
47
54
  ```
48
55
 
49
- The `plastic:update` command shows available versions across all channels and
56
+ The `plastic-update` command shows available versions across all channels and
50
57
  lets you choose which to install.
51
58
 
52
59
  ## Quick Start
53
60
 
54
61
  After installation, run `/clear` to load Plastic conventions, then:
55
62
 
56
- 1. Say "new intent" or run `/plastic:creating-intent` to create your first intent
63
+ 1. Say "new intent" or run `/plastic-creating-intent` to create your first intent
57
64
  2. Describe what you want to accomplish
58
- 3. Use `/plastic:brainstorming` to explore the design
59
- 4. Use `/plastic:writing-plans` to create an implementation plan
60
- 5. Use `/plastic:executing-plan` to deliver it
65
+ 3. Use `/plastic-brainstorming` to explore the design
66
+ 4. Use `/plastic-writing-plans` to create an implementation plan
67
+ 5. Use `/plastic-executing-plan` to deliver it
61
68
 
62
69
  Or say "auto" to let the agent handle the full lifecycle autonomously.
63
70
 
64
71
  ## Conventions
65
72
 
66
73
  All conventions live in `AGENTS.md`, distributed to `~/.plastic/AGENTS.md`
67
- during installation. Run `plastic:doctor` to check installation health.
74
+ during installation. Run `plastic-doctor` to check installation health.
68
75
 
69
76
  ## License
70
77
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: plastic:future-intent-researcher
2
+ name: plastic-future-intent-researcher
3
3
  description: |
4
4
  Use this agent to research future intents autonomously. Picks up parked
5
5
  intents from INDEX.md, investigates them via web search and codebase analysis,
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: plastic:intent-curator
2
+ name: plastic-intent-curator
3
3
  description: |
4
4
  Use this agent when completing or reviewing intents, reorganizing the index,
5
5
  or when the intent store needs maintenance. Examples:
package/deprecations.yml CHANGED
@@ -12,12 +12,13 @@
12
12
  # link: optional URL to migration guide
13
13
 
14
14
  deprecations:
15
- - id: cc-plugin-to-npx
15
+ - id: cc-plugin-to-hyphen-skills
16
16
  severity: warning
17
- summary: "Plastic distribution is moving from Claude Code plugin to npx install"
17
+ summary: "Plastic now ships as flat hyphen-named personal skills (plastic-doctor), not a Claude Code plugin. Invocation changed from plastic:<name> to plastic-<name>."
18
18
  migration_steps:
19
- - "Install new distribution: npx @zalom/plastic@alpha --claude"
20
- - "Verify: check that ~/.plastic/ is intact and hooks work"
21
- - "Remove old plugin: /plugin remove plastic"
19
+ - "Re-run the installer: npx @zalom/plastic@latest --claude (bunx also works)"
20
+ - "It auto-removes the old plugin/marketplace registration no /plugin step needed"
21
+ - "Invoke skills with a hyphen now: /plastic-doctor, /plastic-auto, etc."
22
+ - "Run /plastic-doctor to verify"
22
23
  introduced: "1.0.0"
23
24
  removal: "2.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.15",
3
+ "version": "1.0.0-alpha.17",
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__