@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.
- package/PLASTIC-reference.md +1 -0
- package/PLASTIC.md +13 -7
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-executor.md +5 -5
- package/agents/plastic-planner.md +15 -11
- package/package.json +1 -1
- package/scripts/doctor.rb +44 -0
- package/scripts/feedback-report +54 -0
- package/scripts/lib/bridge.rb +33 -11
- package/scripts/lib/feedback_report.rb +168 -0
- package/scripts/lib/installer_core.rb +4 -0
- package/scripts/lib/skill_lint.rb +304 -0
- package/scripts/skill-lint +50 -0
- package/skills/auto/SKILL.md +10 -7
- package/skills/auto/references/tiers.md +4 -3
- 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/intent-planning/SKILL.md +11 -11
- package/skills/intent-planning/evals/evals.json +20 -5
- package/skills/intent-planning/references/plan-format.md +9 -5
- package/skills/tutorial/references/track-1-guided.md +5 -4
|
@@ -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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
|
74
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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`
|
|
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`
|
|
34
|
-
|
|
35
|
-
`actions/` is
|
|
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
|
|
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
|
|
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):**
|
|
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
|
-
|
|
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`)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 `
|
|
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/
|
|
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}"
|