@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.
@@ -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.4",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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], line: "#{bullet} #{rec[:id]} #{text}#{note}".rstrip }
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: [], line: "+#{ranked.size - NEXT_WORK_CAP} more" }
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` (a flat, rank-ordered list of `{id, intent, scope, lifecycle, value, disposition,
38
- flags, line}`), `counts`, `projects`, `project_totals`. Project-board fields: `slug`,
39
- `store_health`, `description`, `recently_worked`, `next_work`, `counts`, `active`, `future`.
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
- - `{{...lines}}` → join the list's `.line` strings with **real newlines** (one per line).
57
- These are ordinary prose lines, not glyph-led bullets: never add a Markdown `-` bullet,
58
- never emit `<br>`. If a list is empty, render `_(none)_`.
59
- - `next_work.lines` the most-valuable next work, already ranked; each line reads
60
- `"<id> <intent, truncated>"`. Use each entry's `disposition`/`flags` fields when the prose
61
- needs to say why an item is next.
62
- - `projects.lines` → one line per project:
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
- - Board lines are ordinary prose, not a rigid grid: the boards are UI-only and may evolve,
130
- so they need not be valid Markdown lists. Never emit `<br>`.
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
- {{recently_worked.lines}}
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
- {{next_work.lines}}
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
- {{recently_worked.lines}}
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
- {{active.lines}}
14
+
15
+ | Id | What | Stage |
16
+ | --- | --- | --- |
17
+ {{active.rows}}
12
18
 
13
19
  **Future**
14
- {{future.lines}}
20
+
21
+ | Id | What |
22
+ | --- | --- |
23
+ {{future.rows}}
15
24
 
16
25
  ## Most-valuable next work
17
- {{next_work.lines}}
18
26
 
19
- **Legend** · What Why How Exec ● Done
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
- - `{{...lines}}` -> join the list's `.line` strings with real newlines (one per line). These
11
- are ordinary prose lines; never add a `-` prefix, never emit `<br>`. An empty list renders
12
- `_(none)_`.
13
- - `next_work.lines` -> the most-valuable next work, already ranked and capped.
14
- - `active.lines` / `future.lines` (project board) -> one line per intent, already formatted.
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