@zalom/plastic 1.1.2 → 1.1.3

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -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}}