@zalom/plastic 1.1.1 → 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.
@@ -0,0 +1,304 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # SkillLint: deterministic, dependency-injected engine that runs five
7
+ # structural checks over a directory of Agent Skills (intent 85b).
8
+ #
9
+ # Pure and DI: constructed with an injected `skills_dir`, performs no writes,
10
+ # no `eval`, and reads no ambient config beyond the injected directory. Mirrors
11
+ # the CLI-over-lib shape of `scripts/lib/intent_validator.rb`: the `skill-lint`
12
+ # CLI (ACTION_2) is a thin wrapper, `scripts/doctor.rb` (ACTION_4) consumes the
13
+ # same engine for an advisory finding, and `test/skill_lint_test.rb` (ACTION_3)
14
+ # proves every check red-and-green plus a no-skip-list live-tree guard.
15
+ #
16
+ # Each violation is a Hash:
17
+ # { check:, skill:, file:, line:, rule:, message: }
18
+ # `check` is one of the five check-ids below, `skill` is the skill directory
19
+ # basename, `file` is the offending path, `line` is an Integer where locatable
20
+ # else nil, `rule` cites the standard being enforced, `message` is an
21
+ # actionable fix instruction.
22
+ class SkillLint
23
+ # A binding keyword: a mention's paragraph carries an observable trigger
24
+ # condition when it names one of these (word-boundary, case-insensitive).
25
+ BINDING_KEYWORD_RE = /\b(when|if|before|after|while)\b/i
26
+
27
+ # A `to <verb>` or `for <noun-phrase>` purpose clause.
28
+ PURPOSE_RE = /\b(to|for)\s+\S/i
29
+
30
+ Result = Struct.new(:violations) do
31
+ def ok?
32
+ violations.empty?
33
+ end
34
+
35
+ # Slice violations down to one check-id, e.g. for a doctor finding or a
36
+ # red-proof assertion.
37
+ def violations_for(check_id)
38
+ violations.select { |v| v[:check] == check_id }
39
+ end
40
+ end
41
+
42
+ def initialize(skills_dir:)
43
+ @skills_dir = skills_dir
44
+ end
45
+
46
+ def run
47
+ violations = []
48
+
49
+ skill_md_paths.each do |skill_md|
50
+ skill_dir = File.dirname(skill_md)
51
+ content = File.read(skill_md)
52
+
53
+ violations.concat(check_body_budget(skill_dir, skill_md, content))
54
+ violations.concat(check_frontmatter_validity(skill_dir, skill_md, content))
55
+ violations.concat(check_bare_pointer(skill_dir, skill_md, content))
56
+ violations.concat(check_orphan_files(skill_dir))
57
+ violations.concat(check_references_depth(skill_dir))
58
+ end
59
+
60
+ Result.new(violations)
61
+ end
62
+
63
+ private
64
+
65
+ def skill_md_paths
66
+ Dir.glob(File.join(@skills_dir, "*", "SKILL.md")).sort
67
+ end
68
+
69
+ def skill_name(skill_dir)
70
+ File.basename(skill_dir)
71
+ end
72
+
73
+ def violation(check:, skill:, file:, line:, rule:, message:)
74
+ { check: check, skill: skill, file: file, line: line, rule: rule, message: message }
75
+ end
76
+
77
+ # Frontmatter split uses the shared convention (scripts/lib/intent_validator.rb):
78
+ # `content.split("---", 3)`; parts[1] is the frontmatter text, parts[2] is the
79
+ # body. Also computes the body's starting line number (0-based line count of
80
+ # everything before the body) so callers can report absolute file line
81
+ # numbers, not just body-relative ones.
82
+ def frontmatter_and_body(content)
83
+ parts = content.split("---", 3)
84
+ return { frontmatter: nil, body: content, body_offset_lines: 0 } if parts.length < 3
85
+
86
+ prefix_len = parts[0].length + 3 + parts[1].length + 3
87
+ prefix = content[0...prefix_len]
88
+ { frontmatter: parts[1], body: parts[2], body_offset_lines: prefix.count("\n") }
89
+ end
90
+
91
+ # --- 1. body-budget ---
92
+
93
+ def check_body_budget(skill_dir, skill_md, content)
94
+ violations = []
95
+ body = frontmatter_and_body(content)[:body]
96
+ name = skill_name(skill_dir)
97
+
98
+ line_count = body.lines.count
99
+ if line_count >= 500
100
+ violations << violation(
101
+ check: "body-budget", skill: name, file: skill_md, line: nil,
102
+ rule: "body under 500 lines",
103
+ message: "SKILL.md body is #{line_count} lines; move detail into references/*.md to get under 500"
104
+ )
105
+ end
106
+
107
+ tokens = (body.split(/\s+/).reject(&:empty?).length * 1.3).round
108
+ if tokens >= 5000
109
+ violations << violation(
110
+ check: "body-budget", skill: name, file: skill_md, line: nil,
111
+ rule: "body under about 5000 tokens (word-based estimate)",
112
+ message: "SKILL.md body is about #{tokens} tokens (word-based estimate); move detail into references/*.md to get under 5000"
113
+ )
114
+ end
115
+
116
+ violations
117
+ end
118
+
119
+ # --- 2. frontmatter-validity ---
120
+
121
+ def check_frontmatter_validity(skill_dir, skill_md, content)
122
+ violations = []
123
+ name = skill_name(skill_dir)
124
+ frontmatter_text = frontmatter_and_body(content)[:frontmatter]
125
+
126
+ begin
127
+ fm = YAML.safe_load(frontmatter_text.to_s)
128
+ rescue Psych::SyntaxError
129
+ violations << violation(
130
+ check: "frontmatter-validity", skill: name, file: skill_md, line: nil,
131
+ rule: "frontmatter must survive strict YAML.safe_load",
132
+ message: "SKILL.md frontmatter fails YAML.safe_load; quote scalar values that contain an unquoted \": \" sequence"
133
+ )
134
+ return violations
135
+ end
136
+ fm = {} unless fm.is_a?(Hash)
137
+
138
+ expected_name = "plastic-#{name}"
139
+ if fm["name"] != expected_name
140
+ violations << violation(
141
+ check: "frontmatter-validity", skill: name, file: skill_md, line: nil,
142
+ rule: "name: must equal plastic-<directory>",
143
+ message: "frontmatter name: is #{fm["name"].inspect}; expected #{expected_name.inspect}"
144
+ )
145
+ end
146
+
147
+ user_invocable = fm["user-invocable"]
148
+ unless user_invocable == true || user_invocable == false
149
+ violations << violation(
150
+ check: "frontmatter-validity", skill: name, file: skill_md, line: nil,
151
+ rule: "user-invocable: must be present and boolean",
152
+ message: "frontmatter user-invocable: is #{user_invocable.inspect}; must be present and true or false"
153
+ )
154
+ end
155
+
156
+ violations
157
+ end
158
+
159
+ # --- 3. bare-pointer ---
160
+
161
+ def check_bare_pointer(skill_dir, skill_md, content)
162
+ violations = []
163
+ ref_files = Dir.glob(File.join(skill_dir, "references", "*.md")).sort
164
+ return violations if ref_files.empty?
165
+
166
+ parsed = frontmatter_and_body(content)
167
+ body_lines = parsed[:body].lines
168
+ offset = parsed[:body_offset_lines]
169
+ blocks = paragraph_blocks(body_lines)
170
+ name = skill_name(skill_dir)
171
+
172
+ ref_files.each do |ref_file|
173
+ base = File.basename(ref_file)
174
+ mention_indices = (0...body_lines.length).select { |i| body_lines[i].include?(base) }
175
+ next if mention_indices.empty? # zero mentions is an orphan (check 4), not a bare pointer
176
+
177
+ bound = mention_indices.any? { |i| bound_mention?(body_lines[i], i, blocks, base) }
178
+ next if bound
179
+
180
+ first = mention_indices.first
181
+ violations << violation(
182
+ check: "bare-pointer", skill: name, file: skill_md, line: offset + first + 1,
183
+ rule: "every reference link must bind to an observable trigger condition, never a bare pointer",
184
+ message: "#{base} is only ever a bare pointer; add a when/if/before/after/while clause or a " \
185
+ "to/for purpose so the trigger is observable"
186
+ )
187
+ end
188
+
189
+ violations
190
+ end
191
+
192
+ def bound_mention?(line, index, blocks, base)
193
+ stripped = line.strip
194
+ return table_row_bound?(stripped, base) if stripped.start_with?("|")
195
+
196
+ block = blocks.find { |b| index.between?(b[:start], b[:end]) }
197
+ block_text = block ? block[:text] : line
198
+
199
+ # Narrow to the unit(s) that actually mention the reference, so an
200
+ # unrelated unit elsewhere in the same paragraph cannot launder a
201
+ # genuinely bare pointer through an incidental "to"/"for" (an ordinary
202
+ # preposition, not a binding purpose clause). Split on BOTH sentence
203
+ # boundaries (.!?) AND list-item starts, so a bullet list (which has no
204
+ # terminal punctuation between items) does not collapse into one unit
205
+ # whose bound siblings launder a bare bullet -- the trailing "References"
206
+ # list pattern (most bullets bound, one forgotten) this linter exists to
207
+ # catch. Keep only units that mention `base`. Falls back to the whole
208
+ # block when no unit boundary contains the mention (e.g. a mid-sentence
209
+ # line wrap with no terminal punctuation nearby), so the legitimate
210
+ # multi-line-wrapped case still binds.
211
+ #
212
+ # Known conservative limitation (documented, not closed): a bare mention
213
+ # with NO unit boundary of its own (no leading list marker, no preceding
214
+ # terminal punctuation) immediately followed by an unrelated sentence
215
+ # carrying "to"/"for" can still borrow that neighbor's binding via the
216
+ # empty-fallback path. Closing this structurally risks re-breaking the
217
+ # legitimate wrapped-sentence case (which also relies on the fallback),
218
+ # so it stays open; see intent 85b insights.
219
+ units = block_text.split(/(?<=[.!?])\s+|\n(?=\s*(?:[-*+]|\d+[.)])\s)/)
220
+ mentioning = units.select { |s| s.include?(base) }
221
+ scoped_text = mentioning.empty? ? block_text : mentioning.join(" ")
222
+
223
+ scoped_text.match?(BINDING_KEYWORD_RE) || scoped_text.match?(PURPOSE_RE)
224
+ end
225
+
226
+ # A table row is bound when some OTHER cell (not the one carrying the
227
+ # reference path) has visible text (the trigger-condition column).
228
+ def table_row_bound?(stripped_line, base)
229
+ cells = stripped_line.split("|").map(&:strip).reject(&:empty?)
230
+ other_cells = cells.reject { |c| c.include?(base) }
231
+ !other_cells.empty?
232
+ end
233
+
234
+ # Group body lines into blank-line-delimited paragraph blocks so a mention
235
+ # that trails a wrapped sentence (the binding keyword on the line above) is
236
+ # still evaluated against its full paragraph, not just its own physical line.
237
+ def paragraph_blocks(lines)
238
+ blocks = []
239
+ start = nil
240
+ lines.each_with_index do |line, i|
241
+ if line.strip.empty?
242
+ blocks << { start: start, end: i - 1, text: lines[start..(i - 1)].join } if start
243
+ start = nil
244
+ else
245
+ start ||= i
246
+ end
247
+ end
248
+ blocks << { start: start, end: lines.length - 1, text: lines[start..-1].join } if start
249
+ blocks
250
+ end
251
+
252
+ # --- 4. orphan-files ---
253
+
254
+ def check_orphan_files(skill_dir)
255
+ violations = []
256
+ ref_files = Dir.glob(File.join(skill_dir, "references", "*.md")).sort
257
+ return violations if ref_files.empty?
258
+
259
+ other_files = Dir.glob(File.join(skill_dir, "**", "*")).select { |p| File.file?(p) }
260
+ name = skill_name(skill_dir)
261
+
262
+ ref_files.each do |ref_file|
263
+ base = File.basename(ref_file)
264
+ mentioned = other_files.any? do |other|
265
+ next false if other == ref_file
266
+
267
+ File.read(other).include?(base)
268
+ end
269
+ next if mentioned
270
+
271
+ violations << violation(
272
+ check: "orphan-files", skill: name, file: ref_file, line: nil,
273
+ rule: "every references/ file must be routed from the skill",
274
+ message: "#{base} is never mentioned anywhere in the skill directory; route it from SKILL.md or delete it"
275
+ )
276
+ end
277
+
278
+ violations
279
+ end
280
+
281
+ # --- 5. references-depth ---
282
+
283
+ def check_references_depth(skill_dir)
284
+ violations = []
285
+ refs_root = File.join(skill_dir, "references")
286
+ return violations unless File.directory?(refs_root)
287
+
288
+ name = skill_name(skill_dir)
289
+ Dir.glob(File.join(refs_root, "**", "*")).each do |path|
290
+ next unless File.file?(path)
291
+
292
+ rel = path.sub("#{refs_root}/", "")
293
+ next unless rel.include?("/")
294
+
295
+ violations << violation(
296
+ check: "references-depth", skill: name, file: path, line: nil,
297
+ rule: "references stay one level deep",
298
+ message: "#{rel} is nested under references/; move it to a flat references/*.md file"
299
+ )
300
+ end
301
+
302
+ violations
303
+ end
304
+ end
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # skill-lint: deterministic CLI over SkillLint (intent 85b).
6
+ #
7
+ # Runs the five structural skill checks (body-budget, frontmatter-validity,
8
+ # bare-pointer, orphan-files, references-depth) over a directory of Agent
9
+ # Skills and reports every violation. Mirrors `scripts/validate-intent`'s
10
+ # CLI-over-lib shape and exit-code contract.
11
+ #
12
+ # Usage:
13
+ # skill-lint [--skills-dir <path>]
14
+ #
15
+ # Exit codes: 0 (clean), 1 (violations found; reported on stderr), 2 (usage).
16
+ # --skills-dir defaults to the repo skills/ directory next to this script.
17
+
18
+ require_relative "lib/skill_lint"
19
+
20
+ def resolve_skills_dir(args)
21
+ if (i = args.index("--skills-dir"))
22
+ args[i + 1]
23
+ else
24
+ File.expand_path("../skills", __dir__)
25
+ end
26
+ end
27
+
28
+ if ARGV.any? { |a| a.start_with?("--") && a != "--skills-dir" }
29
+ warn "usage: skill-lint [--skills-dir <path>]"
30
+ exit 2
31
+ end
32
+
33
+ if ARGV.include?("--skills-dir") && ARGV[ARGV.index("--skills-dir") + 1].nil?
34
+ warn "usage: skill-lint [--skills-dir <path>]"
35
+ exit 2
36
+ end
37
+
38
+ dir = File.expand_path(resolve_skills_dir(ARGV))
39
+ result = SkillLint.new(skills_dir: dir).run
40
+
41
+ if result.ok?
42
+ puts "OK: #{dir}"
43
+ exit 0
44
+ end
45
+
46
+ warn "VIOLATIONS: #{dir}"
47
+ result.violations.each do |v|
48
+ warn "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line].nil? ? "-" : v[:line]} #{v[:message]}"
49
+ end
50
+ exit 1
@@ -70,8 +70,10 @@ and artifact depth to that size. Extended walkthrough: `references/tiers.md`.
70
70
  every tier and in both modes. A three-line spec.md is still a spec.md, in the same
71
71
  place, under the same gate.
72
72
  3. **Per-tier topology.** S/M: one thinker agent, one boot, two stations, sonnet
73
- executor, `actions/` skipped; S may also skip the QMD discovery deposit when chain and
74
- sources are both empty. L: today's full team (`## Team Spin-Up` below).
73
+ executor; the thinker writes at least one real action file (one consolidated
74
+ `actions/ACTION_1.md`), never an empty `actions/`. S may also skip the QMD discovery
75
+ deposit when chain and sources are both empty. L: today's full team (`## Team Spin-Up`
76
+ below), one `actions/ACTION_N.md` per task.
75
77
  4. **Never-cut list**, any tier or mode: the independent reviewer (separate agent, fresh
76
78
  context, never the maker), `outcome.md` as truth of delivery, the delivery lock,
77
79
  worktree isolation, intent creation via skill, INDEX as status truth, the QMD reindex
@@ -203,14 +205,15 @@ Then proceed to How.
203
205
 
204
206
  ## How Phase
205
207
 
206
- This is the L-tier shape (see `## Tiers` above); S/M skip step 3 and fold the checklist
207
- rationale into plan.md inline. The `actions/` directory itself is scaffolded empty at
208
- intent birth and persists at every tier; only writing `ACTION_N.md` files into it is L
209
- only (S/M leave the directory empty).
208
+ Every tier runs all four steps below (see `## Tiers` above). The `actions/` directory is
209
+ scaffolded (with a `.gitkeep`) at intent birth; the planner then writes at least one REAL
210
+ `ACTION_N.md` into it at every tier. The tier only changes step 3's granularity: S/M write
211
+ one consolidated `actions/ACTION_1.md`, L writes one `actions/ACTION_N.md` per task. A
212
+ `.gitkeep`-only or empty `actions/` fails the How gate.
210
213
 
211
214
  1. If `superpowers:writing-plans` is available as a skill, delegate plan creation to it. Tell it the plan saves to the active intent's directory (not `docs/superpowers/plans/`).
212
215
  2. Otherwise, write `plan.md` directly - implementation plan with numbered tasks
213
- 3. Write `ACTION_N.md` files into the existing `actions/` directory (one per task, self-contained) - L only
216
+ 3. Write at least one real `ACTION_N.md` into the existing `actions/` directory, self-contained (S/M: one consolidated `ACTION_1.md`; L: one per task)
214
217
  4. Write `checklist.md` - execution registry with checkboxes covering all actions
215
218
  5. Notify user (How briefing): brief per `references/human-report-contract.md`
216
219
  (State: the plan shape, task count and what it builds; Risk: the riskiest task or
@@ -30,9 +30,10 @@ the savepoint ledger.
30
30
  One thinker agent boots ONCE and stays in a single context for two stations:
31
31
 
32
32
  1. Station 1 — writes `spec.md` (collapsed sections allowed, one line each is valid).
33
- 2. Station 2 — writes `plan.md` + `checklist.md` in the SAME context (no reboot). plan.md
34
- carries the checklist rationale inline instead of separate `actions/ACTION_N.md` files.
35
- `actions/` is not created for S/M.
33
+ 2. Station 2 — writes `plan.md` + `checklist.md` + at least one real action file in the
34
+ SAME context (no reboot). At S/M the thinker consolidates the whole delivery into one
35
+ `actions/ACTION_1.md` (rather than one file per task); `actions/` is populated at every
36
+ tier, and a `.gitkeep`-only or empty `actions/` fails the How gate.
36
37
 
37
38
  Then a sonnet executor (a fresh dispatch, this is the one topology split that always
38
39
  happens) implements from plan.md + checklist.md, checks off items, appends `## Insights`,
@@ -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}}
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plastic-intent-planning
3
- description: "Write implementation plans from a spec. Produces plan.md, checklist.md, and (L tier only) actions/ in the active intent directory."
3
+ description: "Write implementation plans from a spec. Produces plan.md, checklist.md, and at least one real actions/ACTION_N.md (every tier) in the active intent directory."
4
4
  user-invocable: true
5
5
  ---
6
6
 
@@ -31,12 +31,12 @@ If the spec covers multiple independent subsystems, it should have been broken i
31
31
 
32
32
  ## Tier shapes
33
33
 
34
- Read the spec's stamped `Tier:` line (written by intent-speccing) and pick the plan shape it calls for:
34
+ Read the spec's stamped `Tier:` line (written by intent-speccing) and pick the action shape it calls for. Every tier produces at least one REAL action file in `actions/`; the tier only changes how many:
35
35
 
36
- - **S or M (default):** an inline plan-as-checklist. Tasks live directly in `plan.md` with rationale inline; `checklist.md` mirrors the task list. Do NOT create any `actions/` files. Leave the `actions/` directory empty (or absent).
36
+ - **S or M (default):** write ONE consolidated `actions/ACTION_1.md` that carries the whole ordered delivery (the steps plus the exact changes). `plan.md` still holds the overall map and `checklist.md` still mirrors the task list. You may split into a few action files when that reads cleaner, but one real action file is the floor.
37
37
  - **L (many independent tasks, dispatched in parallel):** self-contained `actions/ACTION_N.md`, one per task, each readable without the plan (see `references/plan-format.md`).
38
38
 
39
- Default to inline for S and M. Reserve `actions/` for L, where tasks are independent enough to hand to separate subagents in parallel. When in doubt, prefer inline; creating unnecessary `actions/` files is a plan failure at S/M tier.
39
+ A `.gitkeep` never counts as an action, and an empty `actions/` fails the How gate at every tier. At S/M, keep it to a single consolidated action file rather than one-per-task; over-splitting a small intent is the S/M failure mode, an empty `actions/` is the tier-wide one.
40
40
 
41
41
  ## File Structure
42
42
 
@@ -97,11 +97,11 @@ If you find issues, fix them inline. No need to re-review, just fix and move on.
97
97
  ## Plastic Artifacts
98
98
 
99
99
  After writing `plan.md`, create `checklist.md` (execution registry following the
100
- FORM: `## In Progress`, `## Completed`, `## Session Log`) in the intent directory.
101
- For L tier only, also create `actions/ACTION_N.md` (one self-contained file per
102
- task, in an `actions/` directory inside the intent directory). For S/M tier, do
103
- not create `actions/` files at all (see Tier shapes above). For the exact format
104
- of both, read `references/plan-format.md`.
100
+ FORM: `## In Progress`, `## Completed`, `## Session Log`) and at least one real
101
+ `actions/ACTION_N.md` (self-contained, in an `actions/` directory inside the intent
102
+ directory). At S/M write one consolidated `actions/ACTION_1.md`; at L write one
103
+ `actions/ACTION_N.md` per task (see Tier shapes above). For the exact format of
104
+ both, read `references/plan-format.md`.
105
105
 
106
106
  ## Owner-decision hard-gate items
107
107
 
@@ -118,12 +118,12 @@ When collecting owner rulings for `[ORCHESTRATOR]` hard-gate items, read
118
118
  ## Gate position
119
119
 
120
120
  - **Before:** `spec.md` exists.
121
- - **Produces:** `plan.md` and `checklist.md` (plus `actions/` for L tier only).
121
+ - **Produces:** `plan.md`, `checklist.md`, and at least one real `actions/ACTION_N.md` (every tier; one consolidated file at S/M, one per task at L).
122
122
  - **Next:** /plastic-intent-executing.
123
123
 
124
124
  ## Git Commit
125
125
 
126
- After writing all artifacts (plan.md, checklist.md, actions/ for L tier), commit to the store:
126
+ After writing all artifacts (plan.md, checklist.md, and the actions/ACTION_N.md files), commit to the store:
127
127
 
128
128
  ```bash
129
129
  cd {store_root} && git add . && git commit -m "docs: plan for intent {id}: {name}"