@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.
- package/PLASTIC.md +18 -18
- package/README.md +13 -6
- package/agents/future-intent-researcher.md +1 -1
- package/agents/intent-curator.md +1 -1
- package/deprecations.yml +6 -5
- package/package.json +1 -1
- package/scripts/dashboard.rb +480 -0
- package/scripts/doctor.rb +37 -30
- package/scripts/hook-auto-arm +3 -3
- package/scripts/hook-continue +1 -1
- package/scripts/hook-gate-check +3 -3
- package/scripts/hook-session-start +1 -1
- package/scripts/install.rb +175 -119
- package/scripts/lib/bridge.rb +1 -1
- package/scripts/migrate-to-global +1 -1
- package/skills/_active-intent-gate.md +1 -1
- package/skills/auto/SKILL.md +18 -6
- package/skills/auto/evals/evals.json +17 -17
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/brainstorming/SKILL.md +8 -8
- package/skills/brainstorming-grill-me/SKILL.md +5 -5
- package/skills/continuing/SKILL.md +16 -4
- package/skills/creating-intent/SKILL.md +1 -1
- package/skills/creating-project/SKILL.md +4 -4
- package/skills/dashboard/SKILL.md +92 -0
- package/skills/doctor/SKILL.md +4 -4
- package/skills/evaluating-skills/SKILL.md +1 -1
- package/skills/executing-plan/SKILL.md +1 -1
- package/skills/install/SKILL.md +32 -8
- package/skills/intent-curator/SKILL.md +3 -3
- package/skills/linking-intents/SKILL.md +1 -1
- package/skills/managing-index/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +3 -3
- package/skills/research/SKILL.md +2 -2
- package/skills/savepoint/SKILL.md +1 -1
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +2 -2
- package/skills/writing-instructions/SKILL.md +2 -2
- package/skills/writing-plans/SKILL.md +5 -5
- 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
|
|
65
|
-
| **Why** | `## Context` + Decisions | `spec.md` | `plastic
|
|
66
|
-
| **How** | Planning | `plan.md` + `actions/` + `checklist.md` | `plastic
|
|
67
|
-
| **Exec** | Execution | `outcome.md` | `plastic
|
|
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
|
|
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
|
|
169
|
-
| Brainstorming, spec writing | `plastic
|
|
170
|
-
| Planning, actions | `plastic
|
|
171
|
-
| Execution, delivery | `plastic
|
|
172
|
-
| Autonomous delivery | `plastic
|
|
173
|
-
| Save/restore state | `plastic
|
|
174
|
-
| Knowledge graph, linking | `plastic
|
|
175
|
-
| Projects, hubs | `plastic
|
|
176
|
-
| Index maintenance | `plastic
|
|
177
|
-
| Releases, deprecations | `plastic
|
|
178
|
-
| Health diagnostics | `plastic
|
|
179
|
-
| Writing agent instructions | `plastic
|
|
180
|
-
| Evaluating skills, evals | `plastic
|
|
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
|
|
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
|
|
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
|
|
59
|
-
4. Use `/plastic
|
|
60
|
-
5. Use `/plastic
|
|
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
|
|
74
|
+
during installation. Run `plastic-doctor` to check installation health.
|
|
68
75
|
|
|
69
76
|
## License
|
|
70
77
|
|
package/agents/intent-curator.md
CHANGED
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-
|
|
15
|
+
- id: cc-plugin-to-hyphen-skills
|
|
16
16
|
severity: warning
|
|
17
|
-
summary: "Plastic
|
|
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
|
-
- "
|
|
20
|
-
- "
|
|
21
|
-
- "
|
|
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
|
@@ -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__
|