@zalom/plastic 1.1.2 → 1.1.4
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-reference.md +1 -0
- package/PLASTIC.md +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +20 -2
- package/scripts/feedback-report +54 -0
- package/scripts/lib/feedback_report.rb +168 -0
- package/scripts/lib/installer_core.rb +2 -0
- package/skills/dashboard/SKILL.md +26 -12
- package/skills/dashboard/evals/evals.json +16 -0
- package/skills/dashboard/templates/dashboard-global.md +8 -2
- package/skills/dashboard/templates/dashboard-project.md +15 -5
- package/skills/feedback/SKILL.md +98 -0
- package/skills/feedback/references/transport-and-privacy.md +65 -0
- package/skills/feedback/report.md +36 -0
- package/skills/project-continuing/SKILL.md +5 -0
- package/skills/project-continuing/evals/evals.json +2 -2
- package/skills/project-continuing/references/board-fill.md +13 -5
package/PLASTIC-reference.md
CHANGED
|
@@ -127,6 +127,7 @@ Detailed conventions live inside the skills that use them, not in this file.
|
|
|
127
127
|
| Index maintenance | `plastic-store-indexing` | — |
|
|
128
128
|
| Releases, deprecations | `plastic-releasing` | deprecation process |
|
|
129
129
|
| Health diagnostics | `plastic-doctor` | three scopes: `--core` (binary install-integrity check, runs on SessionStart), `--store [global\|<slug>]` (per-store check, runs on dashboard load), no flag = full check (runs after every update); gate enforcement, stuck detection |
|
|
130
|
+
| Report a Plastic quirk, bug, or feature idea | `plastic-feedback` | transport and privacy (redaction checklist, why a prefilled URL) |
|
|
130
131
|
| Authoring skills, agents, hooks | `plastic-skill-creating` | progressive disclosure, agentskills.io spec |
|
|
131
132
|
| Evaluating skills, evals | `plastic-skill-evaluating` | eval methodology, convention checks |
|
|
132
133
|
| Create, order, and consume a roadmap of intents | `plastic-roadmap` | file format, operations |
|
package/PLASTIC.md
CHANGED
|
@@ -243,6 +243,11 @@ Beyond the lifecycle agents, Plastic ships thin skills for day-to-day operation:
|
|
|
243
243
|
`plastic-rollback`, intent 55) are thin wrappers over a single pinned
|
|
244
244
|
`npx -y @zalom/plastic@<channel> <verb>` call: initialize or repair an install, advance a
|
|
245
245
|
channel, remove Plastic, and step the local versions ledger.
|
|
246
|
+
- **`plastic-feedback`** (intent 174) turns a described Plastic quirk, bug, or feature idea
|
|
247
|
+
into a redacted local report file and a prefilled GitHub issue URL; only the user can submit
|
|
248
|
+
it. `disable-model-invocation` hides its description from your own context, so if the user
|
|
249
|
+
hits a Plastic quirk, bug, or missing feature, offer to run `/plastic-feedback` yourself
|
|
250
|
+
instead of waiting to be asked; the user still sends it, you never do.
|
|
246
251
|
|
|
247
252
|
## Releases and Versioning
|
|
248
253
|
|
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -551,6 +551,19 @@ def within_24h?(rec)
|
|
|
551
551
|
d && d >= (today - 1)
|
|
552
552
|
end
|
|
553
553
|
|
|
554
|
+
# Collapse whitespace to single spaces, strip, then escape Markdown table pipes so
|
|
555
|
+
# free-text payload data is safe to drop verbatim into a table cell. Block-form gsub
|
|
556
|
+
# avoids replacement-string backslash pitfalls.
|
|
557
|
+
def cell(s)
|
|
558
|
+
s.to_s.gsub(/\s+/, " ").strip.gsub("|") { "\\|" }
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Truncate an intent title to the shared line budget, ellipsis when over.
|
|
562
|
+
def truncate_intent(text)
|
|
563
|
+
t = text.to_s
|
|
564
|
+
t.length > INTENT_LINE_MAX_CHARS ? "#{t[0, INTENT_LINE_MAX_CHARS]}…" : t
|
|
565
|
+
end
|
|
566
|
+
|
|
554
567
|
def worked_row(rec, project_scope)
|
|
555
568
|
glyph = STATUS_GLYPH[rec[:status]]
|
|
556
569
|
proj = rec[:scope] == "global" ? "global" : rec[:scope].sub("project:", "")
|
|
@@ -559,6 +572,7 @@ def worked_row(rec, project_scope)
|
|
|
559
572
|
{
|
|
560
573
|
id: rec[:id], status: rec[:status], glyph: glyph,
|
|
561
574
|
last_accessed_at: rec[:last_accessed_at],
|
|
575
|
+
what: cell(truncate_intent(rec[:intent])), state: status_word, scope: cell(proj),
|
|
562
576
|
line: "#{glyph} #{prefix}#{status_word}: #{rec[:id]} #{rec[:intent]}".strip,
|
|
563
577
|
}
|
|
564
578
|
end
|
|
@@ -578,7 +592,8 @@ def intent_line(rec, bullet)
|
|
|
578
592
|
text = rec[:intent].to_s
|
|
579
593
|
text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
|
|
580
594
|
{ id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
|
|
581
|
-
scope: rec[:scope],
|
|
595
|
+
scope: rec[:scope], what: cell(text), stage: rec[:lifecycle].to_s.capitalize,
|
|
596
|
+
line: "#{bullet} #{rec[:id]} #{text}#{note}".rstrip }
|
|
582
597
|
end
|
|
583
598
|
|
|
584
599
|
# Cap a raw record list to NEXT_WORK_CAP entries, then map to intent_line-shaped
|
|
@@ -589,6 +604,7 @@ def cap_lines(list, bullet)
|
|
|
589
604
|
lines = capped.map { |r| intent_line(r, bullet) }
|
|
590
605
|
if list.size > NEXT_WORK_CAP
|
|
591
606
|
lines << { id: "", intent: "", created: "", bullet: bullet, scope: "",
|
|
607
|
+
what: "+#{list.size - NEXT_WORK_CAP} more", stage: "",
|
|
592
608
|
line: "#{bullet} +#{list.size - NEXT_WORK_CAP} more" }
|
|
593
609
|
end
|
|
594
610
|
lines
|
|
@@ -607,11 +623,13 @@ def next_work(records)
|
|
|
607
623
|
text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
|
|
608
624
|
{ id: r[:id], intent: r[:intent], scope: r[:scope], lifecycle: r[:lifecycle],
|
|
609
625
|
value: r[:value].to_s, disposition: r[:disposition], flags: r[:flags],
|
|
626
|
+
what: cell(text), flags_label: cell(Array(r[:flags]).join(", ")),
|
|
610
627
|
line: "#{r[:id]} #{text}" }
|
|
611
628
|
end
|
|
612
629
|
if ranked.size > NEXT_WORK_CAP
|
|
613
630
|
lines << { id: "", intent: "", scope: "", lifecycle: "", value: "", disposition: "",
|
|
614
|
-
flags: [],
|
|
631
|
+
flags: [], what: "+#{ranked.size - NEXT_WORK_CAP} more", flags_label: "",
|
|
632
|
+
line: "+#{ranked.size - NEXT_WORK_CAP} more" }
|
|
615
633
|
end
|
|
616
634
|
lines
|
|
617
635
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# feedback-report - thin CLI over FeedbackReport (intent 174).
|
|
6
|
+
#
|
|
7
|
+
# Reads a redacted-ready markdown body from STDIN, composes a local report
|
|
8
|
+
# file plus a prefilled GitHub new-issue URL, writes the file, and prints the
|
|
9
|
+
# result as JSON. This script has no send path: it never contacts GitHub and
|
|
10
|
+
# never holds a credential. Only the human, opening the printed URL in their
|
|
11
|
+
# own browser, submits anything.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# feedback-report --title "<short title>" < body.md
|
|
15
|
+
#
|
|
16
|
+
# Exit codes: 0 (success), 1 (error composing/writing the report), 2 (usage).
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require_relative "lib/feedback_report"
|
|
20
|
+
|
|
21
|
+
def parse_title(argv)
|
|
22
|
+
i = argv.index("--title")
|
|
23
|
+
return nil unless i && argv[i + 1]
|
|
24
|
+
|
|
25
|
+
argv[i + 1]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
title = parse_title(ARGV)
|
|
29
|
+
|
|
30
|
+
if title.nil? || title.strip.empty?
|
|
31
|
+
warn 'usage: feedback-report --title "<short title>" < body.md'
|
|
32
|
+
exit 2
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
body = $stdin.read
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
home = File.join(Dir.home, ".plastic")
|
|
39
|
+
engine = FeedbackReport.new(plastic_home: home)
|
|
40
|
+
result = engine.compose(title: title, body: body)
|
|
41
|
+
engine.persist(result)
|
|
42
|
+
|
|
43
|
+
puts JSON.pretty_generate(
|
|
44
|
+
report_path: result.report_path,
|
|
45
|
+
url: result.url,
|
|
46
|
+
encoded_url_bytes: result.encoded_url_bytes,
|
|
47
|
+
truncated: result.truncated,
|
|
48
|
+
page_break_note: result.page_break_note
|
|
49
|
+
)
|
|
50
|
+
exit 0
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
warn "feedback-report error: #{e.message}"
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
# FeedbackReport: deterministic, dependency-injected engine that turns a
|
|
8
|
+
# title and an agent-assembled markdown body into a redacted local report
|
|
9
|
+
# file plus a prefilled GitHub new-issue URL (intent 174).
|
|
10
|
+
#
|
|
11
|
+
# Constructor DI, no `eval`, no ENV reads, no globals, stdlib only. Pure
|
|
12
|
+
# methods (`redact`, `fill_version`, `slug_for`, `report_path`, `build_url`,
|
|
13
|
+
# `apply_cap`, `compose`) plus one explicit side-effecting `persist`. Mirrors
|
|
14
|
+
# the engine-in-lib shape of `scripts/lib/skill_lint.rb`: the `feedback-report`
|
|
15
|
+
# CLI is a thin wrapper, `test/feedback_report_test.rb` proves the engine
|
|
16
|
+
# hermetically against an injected `plastic_home` and a fixed `now`.
|
|
17
|
+
#
|
|
18
|
+
# Trust model: this class never sends anything anywhere. `compose` returns a
|
|
19
|
+
# Result carrying a local file path and a browser URL; only the human, in
|
|
20
|
+
# their own authenticated browser, submits it. There is no send method here
|
|
21
|
+
# and there must never be one (see skills/feedback/references/transport-and-privacy.md).
|
|
22
|
+
class FeedbackReport
|
|
23
|
+
GITHUB_REPO = "zalom/plastic"
|
|
24
|
+
CAP_BYTES = 7500
|
|
25
|
+
|
|
26
|
+
Result = Struct.new(:report_path, :body, :url, :encoded_url_bytes, :truncated, :page_break_note, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# Ordered [Regexp, replacement] pairs. Order matters: `sk-ant-` must be
|
|
29
|
+
# tried before the shorter `sk-` pattern so the longer form wins, and the
|
|
30
|
+
# generic key/value assignment pattern runs last as a catch-all so it does
|
|
31
|
+
# not steal a match a more specific pattern would have redacted more
|
|
32
|
+
# precisely. Each pattern replaces the matched secret span with
|
|
33
|
+
# `[REDACTED]`; the assignment pattern keeps the key name and separator and
|
|
34
|
+
# redacts only the value.
|
|
35
|
+
REDACTIONS = [
|
|
36
|
+
[/\bgh[posru]_[A-Za-z0-9]{20,}\b/, "[REDACTED]"],
|
|
37
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/, "[REDACTED]"],
|
|
38
|
+
[/\bsk-ant-[A-Za-z0-9\-]{20,}\b/, "[REDACTED]"],
|
|
39
|
+
[/\bsk-[A-Za-z0-9]{20,}\b/, "[REDACTED]"],
|
|
40
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "[REDACTED]"],
|
|
41
|
+
[/\bBearer\s+[A-Za-z0-9._\-]{20,}/, "[REDACTED]"],
|
|
42
|
+
[/\bxox[baprs]-[A-Za-z0-9\-]{10,}/, "[REDACTED]"],
|
|
43
|
+
[/\bAIza[0-9A-Za-z_\-]{35}\b/, "[REDACTED]"],
|
|
44
|
+
[/-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/, "[REDACTED]"],
|
|
45
|
+
[/\b(api[_-]?key|secret|token|password)\b(\s*[:=]\s*)\S+/i, '\1\2[REDACTED]'],
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
def initialize(plastic_home:, now: Time.now, github_repo: GITHUB_REPO, cap_bytes: CAP_BYTES)
|
|
49
|
+
@plastic_home = plastic_home
|
|
50
|
+
@now = now
|
|
51
|
+
@github_repo = github_repo
|
|
52
|
+
@cap_bytes = cap_bytes
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Apply every redaction pattern in order and return the cleaned string.
|
|
56
|
+
def redact(text)
|
|
57
|
+
REDACTIONS.reduce(text) { |acc, (pattern, replacement)| acc.gsub(pattern, replacement) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Replace the `{{plastic_version}}` token with the injected VERSION file's
|
|
61
|
+
# content, or the literal string "unknown" when the file is absent.
|
|
62
|
+
def fill_version(body)
|
|
63
|
+
version_file = File.join(@plastic_home, "VERSION")
|
|
64
|
+
version = File.exist?(version_file) ? File.read(version_file).strip : "unknown"
|
|
65
|
+
body.gsub("{{plastic_version}}", version)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Kebab-case a title: downcase, collapse any run of non [a-z0-9] into one
|
|
69
|
+
# hyphen, trim leading/trailing hyphens, cap at ~50 chars. Empty input (or
|
|
70
|
+
# a title with no alphanumerics) falls back to "feedback".
|
|
71
|
+
def slug_for(title)
|
|
72
|
+
slug = title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
73
|
+
slug = slug[0, 50].gsub(/-+\z/, "")
|
|
74
|
+
slug.empty? ? "feedback" : slug
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `{plastic_home}/feedback/{YYYY-MM-DD}--{slug}.md`, first free path. A
|
|
78
|
+
# same-day same-slug collision tries `--2`, `--3`, ... until a free name
|
|
79
|
+
# is found. This only reads the filesystem to check for a collision; it
|
|
80
|
+
# never writes (that is `persist`'s job).
|
|
81
|
+
def report_path(title)
|
|
82
|
+
dir = File.join(@plastic_home, "feedback")
|
|
83
|
+
base = "#{@now.strftime('%Y-%m-%d')}--#{slug_for(title)}"
|
|
84
|
+
|
|
85
|
+
candidate = File.join(dir, "#{base}.md")
|
|
86
|
+
return candidate unless File.exist?(candidate)
|
|
87
|
+
|
|
88
|
+
n = 2
|
|
89
|
+
loop do
|
|
90
|
+
candidate = File.join(dir, "#{base}--#{n}.md")
|
|
91
|
+
return candidate unless File.exist?(candidate)
|
|
92
|
+
|
|
93
|
+
n += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build the prefilled GitHub new-issue URL. ONLY `title` and `body` params,
|
|
98
|
+
# percent-encoded. No `template`, no `labels`.
|
|
99
|
+
def build_url(title, body)
|
|
100
|
+
enc = ->(s) { CGI.escape(s) }
|
|
101
|
+
"https://github.com/#{@github_repo}/issues/new?title=#{enc.call(title)}&body=#{enc.call(body)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# If the full body fits under the byte cap once encoded, return it as-is.
|
|
105
|
+
# Otherwise binary-search the largest prefix of the body that, plus an
|
|
106
|
+
# honest end-marker naming the local (uncapped) report file, still fits,
|
|
107
|
+
# and return that page-one body instead. Returns
|
|
108
|
+
# [url, url_body, truncated, page_break_note].
|
|
109
|
+
def apply_cap(title, redacted_body, path)
|
|
110
|
+
full_url = build_url(title, redacted_body)
|
|
111
|
+
return [full_url, redacted_body, false, nil] if full_url.bytesize <= @cap_bytes
|
|
112
|
+
|
|
113
|
+
end_marker = "\n\n---\nFull report continues in your local file: #{path}\n" \
|
|
114
|
+
"Paste the rest below if relevant."
|
|
115
|
+
|
|
116
|
+
lo = 0
|
|
117
|
+
hi = redacted_body.length
|
|
118
|
+
best_n = 0
|
|
119
|
+
while lo <= hi
|
|
120
|
+
mid = (lo + hi) / 2
|
|
121
|
+
candidate_url = build_url(title, redacted_body[0...mid] + end_marker)
|
|
122
|
+
if candidate_url.bytesize <= @cap_bytes
|
|
123
|
+
best_n = mid
|
|
124
|
+
lo = mid + 1
|
|
125
|
+
else
|
|
126
|
+
hi = mid - 1
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
page_one = redacted_body[0...best_n] + end_marker
|
|
131
|
+
final_url = build_url(title, page_one)
|
|
132
|
+
raise "feedback report exceeds cap_bytes even at page one (#{final_url.bytesize} > #{@cap_bytes})" if final_url.bytesize > @cap_bytes
|
|
133
|
+
|
|
134
|
+
[final_url, page_one, true, end_marker.strip]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Orchestrate: redact the title, fill the version token and redact the
|
|
138
|
+
# body, resolve the report path from the REDACTED title (so a secret in
|
|
139
|
+
# the title never lands in the filename either), then cap the URL. The
|
|
140
|
+
# title is redacted before it ever reaches build_url/apply_cap, so a
|
|
141
|
+
# secret pasted into the title cannot ride the `title=` URL param
|
|
142
|
+
# unredacted. The FULL redacted body always goes to disk; only the URL's
|
|
143
|
+
# body may be the capped page-one.
|
|
144
|
+
def compose(title:, body:)
|
|
145
|
+
redacted_title = redact(title)
|
|
146
|
+
filled = fill_version(body)
|
|
147
|
+
redacted_body = redact(filled)
|
|
148
|
+
path = report_path(redacted_title)
|
|
149
|
+
url, _url_body, truncated, note = apply_cap(redacted_title, redacted_body, path)
|
|
150
|
+
|
|
151
|
+
Result.new(
|
|
152
|
+
report_path: path,
|
|
153
|
+
body: redacted_body,
|
|
154
|
+
url: url,
|
|
155
|
+
encoded_url_bytes: url.bytesize,
|
|
156
|
+
truncated: truncated,
|
|
157
|
+
page_break_note: note
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Write the FULL redacted body to disk. The only side-effecting method on
|
|
162
|
+
# this class.
|
|
163
|
+
def persist(result)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(result.report_path))
|
|
165
|
+
File.write(result.report_path, result.body)
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -298,6 +298,8 @@ class InstallerCore
|
|
|
298
298
|
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
299
299
|
"scripts/skill-lint" => "scripts/skill-lint",
|
|
300
300
|
"scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
|
|
301
|
+
"scripts/feedback-report" => "scripts/feedback-report",
|
|
302
|
+
"scripts/lib/feedback_report.rb" => "scripts/lib/feedback_report.rb",
|
|
301
303
|
}
|
|
302
304
|
end
|
|
303
305
|
|
|
@@ -34,9 +34,13 @@ ruby ~/.plastic/scripts/dashboard.rb [continue|project <slug>] --data
|
|
|
34
34
|
- `project <slug>` → that **project** board payload (`mode: "project"`).
|
|
35
35
|
|
|
36
36
|
The payload is read-only JSON. Global-board fields: `date`, `store_health`, `recently_worked`,
|
|
37
|
-
`next_work`
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
`next_work`, `counts`, `projects`, `project_totals`. Project-board fields: `slug`, `store_health`,
|
|
38
|
+
`description`, `recently_worked`, `next_work`, `counts`, `active`, `future`. Each list carries
|
|
39
|
+
cell-ready fields for its table: `next_work` rows are
|
|
40
|
+
`{id, intent, scope, lifecycle, value, disposition, flags, what, flags_label, line}`;
|
|
41
|
+
`recently_worked` rows carry `{id, status, glyph, last_accessed_at, what, state, scope, line}`;
|
|
42
|
+
`active`/`future` rows carry `{id, intent, created, bullet, scope, what, stage, line}`. The `what`,
|
|
43
|
+
`scope`, and `flags_label` cell fields arrive pipe-escaped and whitespace-normalized.
|
|
40
44
|
|
|
41
45
|
Each board load runs the scoped store check (`doctor --store <scope>`): the global board runs
|
|
42
46
|
`--store global` and a project board runs `--store <slug>`. The result rides in the payload as
|
|
@@ -53,13 +57,22 @@ Templates live in this skill's `templates/` directory:
|
|
|
53
57
|
|
|
54
58
|
Fill mechanically, no rewriting, no re-sorting:
|
|
55
59
|
- `{{a.b.count}}` → the integer (e.g. `counts.active` = that count).
|
|
56
|
-
- `{{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- `
|
|
60
|
+
- `{{<list>.rows}}` → the four intent lists (`recently_worked`, `next_work`, `active`, `future`)
|
|
61
|
+
render as **Markdown table rows**. The template hard-codes each table's header and separator;
|
|
62
|
+
this placeholder becomes one data row per list entry, joined with real newlines, in that table's
|
|
63
|
+
fixed column order (below). Drop each cell from the named payload field **verbatim**: cells
|
|
64
|
+
arrive pre-escaped and whitespace-normalized from the script (pipes escaped as `\|`), so never
|
|
65
|
+
re-escape, re-truncate, or reword them. Never emit `<br>`.
|
|
66
|
+
- `recently_worked` (global) → `| {id} | {what} | {state} | {scope} |`
|
|
67
|
+
- `recently_worked` (project) → `| {id} | {what} | {state} |`
|
|
68
|
+
- `next_work` → `| {id} | {what} | {value} | {disposition} | {flags_label} |`
|
|
69
|
+
- `active` → `| {id} | {what} | {stage} |`
|
|
70
|
+
- `future` → `| {id} | {what} |`
|
|
71
|
+
Overflow entry (empty `id`, `what` = `+N more`) → one row with `+N more` in the Id column and
|
|
72
|
+
every other cell blank. Empty list → one full-width row with `_(none)_` in the Id column and
|
|
73
|
+
every other cell blank, matching that table's column count (e.g. `| _(none)_ | | | | |` for
|
|
74
|
+
the 5-column next_work table, `| _(none)_ | |` for the 2-column future table).
|
|
75
|
+
- `{{projects.lines}}` → the project rollup stays **prose**, one line per project (not a table):
|
|
63
76
|
`- **{slug}**: {description}, active {active}, done {done}, future {future}, last accessed {last_accessed_at[0,10]}`.
|
|
64
77
|
- Scalars (`{{date}}`, `{{slug}}`, `{{description}}`) → substitute verbatim.
|
|
65
78
|
|
|
@@ -126,7 +139,8 @@ intentional change means the skill is broken.
|
|
|
126
139
|
|
|
127
140
|
## Notes
|
|
128
141
|
|
|
129
|
-
-
|
|
130
|
-
|
|
142
|
+
- The four intent lists (recently worked, active, future, next work) render as Markdown tables;
|
|
143
|
+
the narrative wrappers, counts, and the project rollup stay prose. No Value x Effort grid
|
|
144
|
+
returns. Never emit `<br>`.
|
|
131
145
|
- Clusters (Zettelkasten grouping in INDEX.md) are intentionally not rendered.
|
|
132
146
|
- Additive: changes no core lifecycle, gate, or cycle logic.
|
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
"result": "pass"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": 2,
|
|
23
|
+
"scope": "behavior",
|
|
24
|
+
"set": "validation",
|
|
25
|
+
"prompt": "How do the four intent lists render on the Markdown boards?",
|
|
26
|
+
"expected_output": "recently_worked, next_work, active, and future render as Markdown tables (fixed columns: next_work Id|What|Value|Disposition|Flags; active Id|What|Stage; future Id|What; recently_worked Id|What|State, plus a Scope column on the global board). The narrative wrappers, counts, and the projects rollup stay prose; no Value x Effort grid. Cells arrive pre-escaped from the payload and are dropped verbatim.",
|
|
27
|
+
"files": ["skills/dashboard/templates/dashboard-global.md", "skills/dashboard/templates/dashboard-project.md", "skills/dashboard/SKILL.md"],
|
|
28
|
+
"assertions": [
|
|
29
|
+
{
|
|
30
|
+
"type": "convention",
|
|
31
|
+
"check": "the four intent lists render as Markdown tables with the fixed columns; wrappers and the projects rollup stay prose; no grid",
|
|
32
|
+
"observed": "templates hard-code table headers plus {{list.rows}} for recently_worked/next_work/active/future; SKILL.md Step 2 documents the fixed column order and verbatim pre-escaped cell fill; projects stays {{projects.lines}} prose",
|
|
33
|
+
"result": "pass"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
20
36
|
}
|
|
21
37
|
]
|
|
22
38
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# 🧩 Plastic · Global Board, {{date}}
|
|
2
2
|
|
|
3
3
|
**Recently worked** (last 24h)
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
| Id | What | State | Scope |
|
|
6
|
+
| --- | --- | --- | --- |
|
|
7
|
+
{{recently_worked.rows}}
|
|
5
8
|
|
|
6
9
|
## Where we are
|
|
7
10
|
|
|
@@ -11,6 +14,9 @@
|
|
|
11
14
|
{{projects.lines}}
|
|
12
15
|
|
|
13
16
|
## Most-valuable next work
|
|
14
|
-
|
|
17
|
+
|
|
18
|
+
| Id | What | Value | Disposition | Flags |
|
|
19
|
+
| --- | --- | --- | --- | --- |
|
|
20
|
+
{{next_work.rows}}
|
|
15
21
|
|
|
16
22
|
**What would you like to work on next?** (type an **intent id**, a **project name**, or anything **new** you'd like to start)
|
|
@@ -3,19 +3,29 @@
|
|
|
3
3
|
{{description}}
|
|
4
4
|
|
|
5
5
|
**Recently worked** (last active work, last 24h)
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
| Id | What | State |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
{{recently_worked.rows}}
|
|
7
10
|
|
|
8
11
|
## Intents, active {{counts.active}}, done {{counts.done}}, future {{counts.future}}
|
|
9
12
|
|
|
10
13
|
**Active**
|
|
11
|
-
|
|
14
|
+
|
|
15
|
+
| Id | What | Stage |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
{{active.rows}}
|
|
12
18
|
|
|
13
19
|
**Future**
|
|
14
|
-
|
|
20
|
+
|
|
21
|
+
| Id | What |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
{{future.rows}}
|
|
15
24
|
|
|
16
25
|
## Most-valuable next work
|
|
17
|
-
{{next_work.lines}}
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
| Id | What | Value | Disposition | Flags |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
29
|
+
{{next_work.rows}}
|
|
20
30
|
|
|
21
31
|
**What would you like to work on next?** (type an **intent id**, or **global** to go back)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plastic-feedback
|
|
3
|
+
description: Use when the user hits a Plastic quirk, bug, or feature idea in a project and wants to report it back to the Plastic project. Builds a sanitized report file and a prefilled GitHub issue URL the user reviews and submits. Only the user sends.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
user-invocable: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Plastic Feedback
|
|
9
|
+
|
|
10
|
+
Turn a described Plastic problem into a local report file and a prefilled GitHub
|
|
11
|
+
issue URL. The script does the mechanics (redaction, naming, URL building); the
|
|
12
|
+
user alone opens the URL and submits it. This skill has no send step, by design.
|
|
13
|
+
|
|
14
|
+
Because `disable-model-invocation` hides this skill's description from your own
|
|
15
|
+
context, you cannot discover it by browsing available skills mid-task. If the
|
|
16
|
+
user hits a Plastic quirk, bug, or missing feature, offer to run
|
|
17
|
+
`/plastic-feedback` yourself; do not wait for the user to ask for it by name.
|
|
18
|
+
|
|
19
|
+
## Procedure
|
|
20
|
+
|
|
21
|
+
### 1. Gather the narrative
|
|
22
|
+
|
|
23
|
+
Ask the user for:
|
|
24
|
+
- What happened (the observed behavior).
|
|
25
|
+
- The root cause, if they already know it.
|
|
26
|
+
- The expected behavior.
|
|
27
|
+
|
|
28
|
+
Keep it to about one page. Do not pad it with speculation; a short, accurate
|
|
29
|
+
report beats a long, padded one.
|
|
30
|
+
|
|
31
|
+
### 2. Obfuscate before it leaves this session
|
|
32
|
+
|
|
33
|
+
Before filling the template, strip anything that identifies the user's project
|
|
34
|
+
or its content:
|
|
35
|
+
- Remove project names, directory paths, and file names specific to the user's
|
|
36
|
+
codebase.
|
|
37
|
+
- Turn any Plastic intent names into their bare numeric or slug ids (drop the
|
|
38
|
+
descriptive title if it leaks project context).
|
|
39
|
+
- Keep only Plastic's own operational content: what Plastic did, what it should
|
|
40
|
+
have done, which command or hook was involved.
|
|
41
|
+
|
|
42
|
+
Read `references/transport-and-privacy.md` before filling the template, for the
|
|
43
|
+
full obfuscation checklist and the reasoning behind it.
|
|
44
|
+
|
|
45
|
+
### 3. Fill the report template
|
|
46
|
+
|
|
47
|
+
Read `report.md` from this skill's directory (`~/.plastic/skills/feedback/report.md`
|
|
48
|
+
at runtime, or the plugin source `skills/feedback/report.md` during development).
|
|
49
|
+
Fill every placeholder except `{{plastic_version}}`, which the script fills.
|
|
50
|
+
Assemble the final markdown body from the filled template.
|
|
51
|
+
|
|
52
|
+
### 4. Run the script
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ruby ~/.plastic/scripts/feedback-report --title "<short title>"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Pipe the filled body on STDIN. Parse the JSON on stdout:
|
|
59
|
+
|
|
60
|
+
| Key | Meaning |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `report_path` | Local file the full, uncapped report was written to |
|
|
63
|
+
| `url` | Prefilled GitHub new-issue URL |
|
|
64
|
+
| `encoded_url_bytes` | Byte length of the encoded URL |
|
|
65
|
+
| `truncated` | Whether the URL body is a capped page-one, not the full report |
|
|
66
|
+
| `page_break_note` | The end-marker text appended when `truncated` is true, else null |
|
|
67
|
+
|
|
68
|
+
The script only ever writes a local file and prints a URL. It has no network
|
|
69
|
+
call, no token, and no way to open a browser or submit anything on its own.
|
|
70
|
+
|
|
71
|
+
### 5. Present the result
|
|
72
|
+
|
|
73
|
+
Show the user:
|
|
74
|
+
- The local file path (`report_path`).
|
|
75
|
+
- A short preview of the report.
|
|
76
|
+
- The URL.
|
|
77
|
+
|
|
78
|
+
If `truncated` is true, tell the user plainly: the URL carries page one of the
|
|
79
|
+
report, and the full report is in the local file at `report_path`. They can
|
|
80
|
+
paste more from the local file into the opened issue if they want.
|
|
81
|
+
|
|
82
|
+
Then tell them, in these words or close to them: open the URL, review it, drag
|
|
83
|
+
a screenshot onto the form if they have one, and submit it under their own
|
|
84
|
+
GitHub account. Or, if they would rather edit first, copy the local file
|
|
85
|
+
contents into a new issue themselves.
|
|
86
|
+
|
|
87
|
+
### 6. Never submit
|
|
88
|
+
|
|
89
|
+
State plainly that this skill has no send step: it never posts to GitHub, never
|
|
90
|
+
runs `gh issue create`, and never opens a browser on the user's behalf. The user
|
|
91
|
+
is the only one who can submit the report.
|
|
92
|
+
|
|
93
|
+
## Gotchas
|
|
94
|
+
|
|
95
|
+
- If the described report is long, the script may hand back `truncated: true`.
|
|
96
|
+
This is expected, not an error: the local file always holds the full text.
|
|
97
|
+
- Do not try to route around the missing send step (no `gh` call, no API POST).
|
|
98
|
+
The absence of a send path is the point of this skill, not a gap to fill.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Transport and Privacy
|
|
2
|
+
|
|
3
|
+
Read this before filling `report.md` and before presenting the URL to the user.
|
|
4
|
+
|
|
5
|
+
## Obfuscation checklist (do this before filling the template)
|
|
6
|
+
|
|
7
|
+
Run through this list on the narrative gathered from the user, before it goes
|
|
8
|
+
into `report.md`:
|
|
9
|
+
|
|
10
|
+
- Strip project names. Refer to "the project" or "a consumer project", never
|
|
11
|
+
the user's actual project name.
|
|
12
|
+
- Strip file paths and directory names specific to the user's codebase.
|
|
13
|
+
- Turn Plastic intent names into their bare ids. Drop the descriptive title if
|
|
14
|
+
it names project content (an intent title like "Fix the checkout flow" leaks
|
|
15
|
+
what the user is building; "intent 42" does not).
|
|
16
|
+
- Keep only Plastic's own operational content: which command, hook, or skill
|
|
17
|
+
ran, what it did, what it should have done instead.
|
|
18
|
+
- Before presenting the URL, re-read the filled report once and confirm none
|
|
19
|
+
of the above slipped back in.
|
|
20
|
+
|
|
21
|
+
## Mechanical redaction (what the script also strips)
|
|
22
|
+
|
|
23
|
+
`scripts/lib/feedback_report.rb` redacts these patterns to `[REDACTED]` before
|
|
24
|
+
the report ever touches disk, as a second, mechanical layer under the
|
|
25
|
+
obfuscation above:
|
|
26
|
+
|
|
27
|
+
| Secret kind | Pattern shape |
|
|
28
|
+
|---|---|
|
|
29
|
+
| GitHub tokens | `ghp_`, `gho_`, `ghs_`, `ghr_`, `ghu_`, `github_pat_` prefixes |
|
|
30
|
+
| Anthropic/OpenAI keys | `sk-ant-...`, `sk-...` |
|
|
31
|
+
| AWS access key id | `AKIA...` |
|
|
32
|
+
| Bearer tokens | `Bearer <token>` |
|
|
33
|
+
| Slack tokens | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` prefixes |
|
|
34
|
+
| Google API keys | `AIza...` |
|
|
35
|
+
| PEM private key blocks | `-----BEGIN ... PRIVATE KEY----- ... -----END ... PRIVATE KEY-----` |
|
|
36
|
+
| Key/value assignments | `api_key = ...`, `secret: ...`, `token = ...`, `password: ...` (value only) |
|
|
37
|
+
|
|
38
|
+
Treat this list as a safety net, not the primary defense. The mechanical
|
|
39
|
+
patterns catch a specific, known shape; the obfuscation pass above is what
|
|
40
|
+
catches project-identifying context a regex cannot recognize.
|
|
41
|
+
|
|
42
|
+
## Why a prefilled URL, and not something else
|
|
43
|
+
|
|
44
|
+
The report is sent by opening a prefilled `https://github.com/zalom/plastic/issues/new`
|
|
45
|
+
URL in the user's own browser. Submission happens in an authenticated session
|
|
46
|
+
that belongs to the user, not to the agent or the script. Nothing in this
|
|
47
|
+
skill or in `feedback-report` can complete that submission on its own: there
|
|
48
|
+
is no send method, no token, and no network call anywhere in the code path.
|
|
49
|
+
|
|
50
|
+
Other transports were considered and rejected:
|
|
51
|
+
|
|
52
|
+
- **`gh issue create`**: the CLI can send on its own; only `--web` is
|
|
53
|
+
browser-submitted, and the plain form cannot be guaranteed not to send
|
|
54
|
+
directly. It also assumes `gh` auth, which a consumer-project user may not
|
|
55
|
+
have.
|
|
56
|
+
- **An API POST with a token**: the agent could send it, and the token itself
|
|
57
|
+
becomes a credential worth stealing.
|
|
58
|
+
- **An anonymous POST endpoint**: still agent-reachable, with no built-in spam
|
|
59
|
+
resistance, and it needs server infrastructure this project does not run.
|
|
60
|
+
- **Email or `git send-email`**: the CLI sends the message, review is opt-in
|
|
61
|
+
rather than forced, and it needs a working mail transport most machines do
|
|
62
|
+
not have configured.
|
|
63
|
+
|
|
64
|
+
Only the prefilled-URL approach makes "the agent cannot send" a structural
|
|
65
|
+
fact instead of a rule the agent could break by taking a shortcut.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Plastic feedback: {{title}}
|
|
2
|
+
|
|
3
|
+
<!-- =======================================================================
|
|
4
|
+
AGENT INSTRUCTIONS -- How to fill this template
|
|
5
|
+
=========================================================================
|
|
6
|
+
1. Replace every {{placeholder}} below with real content gathered from the
|
|
7
|
+
user, except {{plastic_version}}: leave that token exactly as written,
|
|
8
|
+
the feedback-report script fills it from the installed VERSION file.
|
|
9
|
+
2. Obfuscate first (see references/transport-and-privacy.md): strip project
|
|
10
|
+
names, file paths, and anything else that identifies the user's
|
|
11
|
+
codebase. Keep only Plastic's own operational content.
|
|
12
|
+
3. Keep the report to about one page. Use tables or short lists where they
|
|
13
|
+
make the report clearer than prose.
|
|
14
|
+
4. Delete this entire HTML comment block before piping the body into
|
|
15
|
+
feedback-report. It is fill instructions only, not report content.
|
|
16
|
+
======================================================================= -->
|
|
17
|
+
|
|
18
|
+
## Environment
|
|
19
|
+
|
|
20
|
+
| Field | Value |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Plastic version | {{plastic_version}} |
|
|
23
|
+
| Agent | {{agent_name}} |
|
|
24
|
+
| OS | {{os}} |
|
|
25
|
+
|
|
26
|
+
## What happened
|
|
27
|
+
|
|
28
|
+
{{what_happened}}
|
|
29
|
+
|
|
30
|
+
## Root cause (if known)
|
|
31
|
+
|
|
32
|
+
{{root_cause_or_not_known}}
|
|
33
|
+
|
|
34
|
+
## Expected behavior
|
|
35
|
+
|
|
36
|
+
{{expected_behavior}}
|
|
@@ -91,6 +91,11 @@ was built against the live INDEX.md-parsing `--data` path (147, the DB cutover,
|
|
|
91
91
|
landed). Its rule-name citations (`classification.md`, cited by name, not logic) and the
|
|
92
92
|
`dashboard.rb project <slug> --data` -> `dashboard-project.md` path still resolve.
|
|
93
93
|
|
|
94
|
+
Intent 149a has landed on top of 149: the four intent lists (recently worked, active, future,
|
|
95
|
+
next work) now render as Markdown tables. The prose demotion and the no-grid stance are unchanged,
|
|
96
|
+
and the rule-name citations and the `dashboard.rb project <slug> --data` -> `dashboard-project.md`
|
|
97
|
+
path still resolve.
|
|
98
|
+
|
|
94
99
|
Intent 148 landed: roadmaps are the primary planning surface. When the tier has a mid-flight
|
|
95
100
|
roadmap (`ruby ~/.plastic/scripts/roadmap-next --roadmaps-dir <tier>/roadmaps` reports a `state`
|
|
96
101
|
other than `none`), the roadmap route (`plastic-roadmap-continuing`) is the live surface for
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
{
|
|
73
73
|
"id": 8, "scope": "behavior", "set": "validation",
|
|
74
74
|
"prompt": "Does the skill carry the intent-149 coordination note?",
|
|
75
|
-
"expected_output": "A 'Coordination' section records that intent 149 has landed (dashboard demoted to prose, no Value x Effort grid) and confirms the rule-name citations and the dashboard.rb project <slug> --data -> dashboard-project.md path still resolve.",
|
|
75
|
+
"expected_output": "A 'Coordination' section records that intent 149 has landed (dashboard demoted to prose, no Value x Effort grid) and intent 149a has landed (the four intent lists render as Markdown tables), and confirms the rule-name citations and the dashboard.rb project <slug> --data -> dashboard-project.md path still resolve.",
|
|
76
76
|
"files": ["skills/project-continuing/SKILL.md"],
|
|
77
77
|
"assertions": [
|
|
78
|
-
{ "type": "convention", "check": "'149' present with the landed coordination note", "observed": "present: 'Intent 149 has landed' with confirmed rule-name citations and dashboard.rb path", "result": "pass" }
|
|
78
|
+
{ "type": "convention", "check": "'149' present with the landed coordination note", "observed": "present: 'Intent 149 has landed' and 'Intent 149a has landed' (lists render as Markdown tables) with confirmed rule-name citations and dashboard.rb path", "result": "pass" }
|
|
79
79
|
]
|
|
80
80
|
},
|
|
81
81
|
{
|
|
@@ -7,11 +7,19 @@ would otherwise bloat the SKILL.md body.
|
|
|
7
7
|
## Fill rules (owned by plastic-dashboard, summarized here for convenience)
|
|
8
8
|
|
|
9
9
|
- `{{a.b.count}}` -> the integer (e.g. `counts.active` is that count).
|
|
10
|
-
- `{{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
10
|
+
- `{{<list>.rows}}` -> the four intent lists (`recently_worked`, `next_work`, `active`, `future`)
|
|
11
|
+
render as Markdown table rows. The template hard-codes each table's header and separator; the
|
|
12
|
+
placeholder becomes one data row per entry, in that table's fixed column order, cells dropped
|
|
13
|
+
verbatim from the payload (cells arrive pipe-escaped and whitespace-normalized; do not re-escape
|
|
14
|
+
or re-truncate). Column order per table:
|
|
15
|
+
- `recently_worked` -> `| id | what | state | scope |` (global), `| id | what | state |` (project)
|
|
16
|
+
- `next_work` -> `| id | what | value | disposition | flags_label |`
|
|
17
|
+
- `active` -> `| id | what | stage |`; `future` -> `| id | what |`
|
|
18
|
+
Overflow entry (empty `id`, `what` = `+N more`) -> `+N more` in the Id column, other cells blank.
|
|
19
|
+
Empty list -> one full-width row with `_(none)_` in the Id column, other cells blank, matching
|
|
20
|
+
that table's column count (e.g. `| _(none)_ | | | | |` for the 5-column next_work table,
|
|
21
|
+
`| _(none)_ | |` for the 2-column future table). Never emit `<br>`.
|
|
22
|
+
- `{{projects.lines}}` (global board) -> the project rollup stays prose, one line per project.
|
|
15
23
|
- Scalars (`{{date}}`, `{{slug}}`, `{{description}}`) -> substitute verbatim.
|
|
16
24
|
|
|
17
25
|
No re-sorting, no re-summarizing, no hand-written prose replacing a line the payload already
|