@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.
@@ -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
@@ -19,7 +19,7 @@ store/
19
19
  plan.md # optional - implementation plan (How deliverable)
20
20
  checklist.md # optional - execution registry (How deliverable)
21
21
  outcome.md # optional - detailed result (Exec deliverable)
22
- actions/ # optional - individual work items
22
+ actions/ # optional - real action files (How deliverable, at least one required)
23
23
  resources/ # optional - research, references, screenshots, diagrams
24
24
  savepoint.md # optional - deterministic cycle-step ledger (auto-written)
25
25
  revisions.md # optional - append-only structural-maintenance audit trail
@@ -95,7 +95,7 @@ tags: [plastic, architecture]
95
95
  |-------|---------|-------------|--------|
96
96
  | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-intent-creating` |
97
97
  | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-brainstorming` |
98
- | **How** | Planning | `plan.md` + `actions/` + `checklist.md` | `plastic-intent-planning` |
98
+ | **How** | Planning | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `plastic-intent-planning` |
99
99
  | **Exec** | Execution | `outcome.md` | `plastic-intent-executing` |
100
100
 
101
101
  `## Insights` is the append-only log of durable discoveries captured throughout ALL stages.
@@ -133,8 +133,9 @@ same-structure invariant holds: same file set, stage order, gates, and savepoint
133
133
  every tier and in both modes.
134
134
 
135
135
  S/M collapse the topology (one thinker agent writes spec.md then plan.md plus
136
- checklist.md in one context; actions/ only for L; a sonnet executor implements). L keeps
137
- the full team.
136
+ checklist.md plus at least one real action file in one context, consolidated into a single
137
+ actions/ACTION_1.md at S/M and one file per task at L; a sonnet executor implements). L
138
+ keeps the full team.
138
139
 
139
140
  Never cut at any tier: the independent reviewer, outcome.md as truth of delivery, the
140
141
  delivery lock, worktree isolation, intent creation via skill, INDEX as status truth, the
@@ -242,6 +243,11 @@ Beyond the lifecycle agents, Plastic ships thin skills for day-to-day operation:
242
243
  `plastic-rollback`, intent 55) are thin wrappers over a single pinned
243
244
  `npx -y @zalom/plastic@<channel> <verb>` call: initialize or repair an install, advance a
244
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.
245
251
 
246
252
  ## Releases and Versioning
247
253
 
@@ -425,8 +431,8 @@ purge. See `docs/internals.md` for depth.
425
431
  | Transition | Trigger | Gate |
426
432
  |---|---|---|
427
433
  | What → Why | `spec.md` written | (none) |
428
- | Why → How | `plan.md` + `actions/` + `checklist.md` | `spec.md` must exist |
429
- | How → Exec | Checklist has items | Plan triplet must exist |
434
+ | Why → How | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `spec.md` must exist |
435
+ | How → Exec | Checklist has items | plan.md, checklist.md, and at least one real actions/ACTION_N.md must exist |
430
436
  | Exec → Done | `outcome.md` written | All checklist items checked |
431
437
 
432
438
  Hard blocking: hooks exit code 2 on gate failure.
@@ -530,7 +536,7 @@ each station.
530
536
  | Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
531
537
  | What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
532
538
  | Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
533
- | How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
539
+ | How | `plan.md`, `actions/ACTION_N.md` (at least one), `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md, checklist.md, and a real action file all exist | gate-check requires spec.md before plan.md, and plan.md plus a real actions/ACTION_N.md before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
534
540
  | Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
535
541
  | End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the bridge is purge-eligible, and the QMD reindex runs LAST (after purge) | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
536
542
  | Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
@@ -31,8 +31,9 @@ is honored as written.
31
31
  4. **Sequence the team** — dispatch specialists per the chosen topology with a constructed
32
32
  context bundle:
33
33
  - S/M: ONE thinker agent, one boot, two stations — it writes spec.md, then plan.md +
34
- checklist.md, in a single context. Sections may be one line each; plan.md carries the
35
- checklist rationale inline; `actions/` files appear only for L. S may skip the QMD
34
+ checklist.md + at least one real action file, in a single context. Sections may be one
35
+ line each; the thinker writes one consolidated `actions/ACTION_1.md` (never an empty
36
+ `actions/`), while L writes one `actions/ACTION_N.md` per task. S may skip the QMD
36
37
  discovery deposit when chain and sources are both empty. A sonnet executor implements.
37
38
  - L: today's full team, one specialist per stage (brainstorming, spec-specialist, planner,
38
39
  executor), each in a fresh context.
@@ -19,11 +19,11 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
19
19
 
20
20
  ## How You Work
21
21
 
22
- 1. Receive (input handoff): `plan.md` and `checklist.md` from the planner (plus
23
- `ACTION_N.md` files inside `actions/` at L; at S/M the `actions/` directory exists but
24
- stays empty, so read the tier fork from the inline plan-as-checklist in plan.md
25
- instead). For S/M intents you run on the sonnet default, the collapsed topology's
26
- implementer; behavior is otherwise unchanged.
22
+ 1. Receive (input handoff): `plan.md`, `checklist.md`, and at least one real `ACTION_N.md`
23
+ inside `actions/` from the planner (S/M hand you one consolidated `actions/ACTION_1.md`;
24
+ L hands you one `actions/ACTION_N.md` per task). Execute the action files in order. For
25
+ S/M intents you run on the sonnet default, the collapsed topology's implementer; behavior
26
+ is otherwise unchanged.
27
27
  2. Work one action at a time, preferring safe, non-destructive routes
28
28
  3. Edit project code (the gate is open now that plan and checklist exist)
29
29
  4. Run the full suite, iterate to zero failures and zero errors
@@ -14,14 +14,17 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
14
14
 
15
15
  1. **Decompose the spec** — break the approach into ordered, independent actions
16
16
  2. **Write the plan** — produce `plan.md` with numbered tasks and verification
17
- 3. **Write self-contained actions, tier-forked**:
18
- - S/M: `plan.md` carries the checklist rationale INLINE; do NOT write
19
- `actions/ACTION_N.md` files. Sections may be one line each.
20
- - L: keep today's shape one self-contained `actions/ACTION_N.md` per task.
17
+ 3. **Write at least one real action file at every tier, tier-forked**:
18
+ - S/M: write ONE self-contained `actions/ACTION_1.md` that consolidates the whole
19
+ ordered delivery (the steps plus the exact changes). You MAY split into a few files
20
+ when that reads cleaner, but one real action file is the floor.
21
+ - L: one self-contained `actions/ACTION_N.md` per task. A `.gitkeep` never counts as an
22
+ action, and an empty `actions/` fails the How gate.
21
23
  4. **Write the checklist** — `checklist.md` as the execution registry covering every action.
22
- `checklist.md` exists at every tier: the file set does not change by tier, only depth
23
- (inline vs action files) and agent topology do. plan.md + checklist.md are the two files
24
- that open the code gate at every tier.
24
+ `checklist.md` and at least one real action file exist at every tier: the file set does not
25
+ change by tier, only action DEPTH (one consolidated action for S/M vs one-per-task for L)
26
+ and agent topology do. plan.md, checklist.md, AND a real `actions/ACTION_N.md` are what open
27
+ the code gate at every tier.
25
28
 
26
29
  ## How You Work
27
30
 
@@ -38,7 +41,7 @@ END your turn with a structured completion report as your final message, per the
38
41
  - The ordered actions, one line each: what the action does and how it is verified
39
42
  - Decomposition rationale: why this order, and why the actions are independent
40
43
  - Checklist coverage: the item count, and that every action plus suite-green is covered
41
- - Which tier shape was produced: inline plan-as-checklist (S/M) or full `actions/` (L)
44
+ - Which tier shape was produced: one consolidated `actions/ACTION_1.md` (S/M) or one `actions/ACTION_N.md` per task (L)
42
45
 
43
46
  The plan is an argument; the orchestrator gates on whether that argument is sound before any code is written, so make the report make that case.
44
47
 
@@ -46,7 +49,8 @@ The plan is an argument; the orchestrator gates on whether that argument is soun
46
49
 
47
50
  - You are dispatched by the plastic-enforcer and your deliverable is gated before Exec begins
48
51
  - You write only intent-store files (`plan.md`, `actions/`, `checklist.md`); no project code
49
- - The code gate stays closed until `plan.md` and `checklist.md` exist, so produce both
52
+ - The code gate stays closed until `plan.md`, `checklist.md`, and at least one real `actions/ACTION_N.md` exist, so produce all three
50
53
  - Keep each action self-contained so the executor can run them one at a time
51
- - The `actions/` empty-directory git-tracking edge (present only within one same-session
52
- local delivery) is out of scope; do not add a `.gitkeep` or a guard for it here
54
+ - Write real `actions/ACTION_N.md` files, never a `.gitkeep`: keeping a freshly-scaffolded
55
+ empty `actions/` under git is `scripts/new-intent`'s job at intent birth, not the planner's,
56
+ and a `.gitkeep` never counts as an action
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -25,6 +25,7 @@ require_relative "lib/lock"
25
25
  require_relative "lib/bridge"
26
26
  require_relative "lib/agent_models"
27
27
  require_relative "lib/legacy_bookend_amnesty"
28
+ require_relative "lib/skill_lint"
28
29
 
29
30
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
30
31
  # run it hermetically (no eval, no global-constant rewriting).
@@ -1805,6 +1806,48 @@ class Doctor
1805
1806
  )
1806
1807
  end
1807
1808
 
1809
+ # --- Check category: skill-lint (advisory only; intent 85b) ---
1810
+ #
1811
+ # Reports SkillLint's five structural checks over the skills/ tree as a
1812
+ # single ADVISORY finding. Always status: "pass", so it can never contribute
1813
+ # to doctor's warn/fail exit code (summarize buckets purely by status; the
1814
+ # lint verdict belongs to a future ship gate, intent 100, not to doctor).
1815
+ # Resolves the same repo skills/ directory `scripts/skill-lint` defaults to,
1816
+ # relative to this script's own location. Skills install flat and renamed
1817
+ # (plastic-<name>) under each agent's own skills/ dir, a different directory
1818
+ # shape SkillLint's frontmatter name-check does not target, so an installed
1819
+ # ~/.plastic/scripts/doctor.rb with no sibling skills/ tree has nothing in
1820
+ # scope here and reports a silent pass rather than guessing at a mismatched
1821
+ # layout.
1822
+ def check_skill_lint
1823
+ dir = File.expand_path("../skills", __dir__)
1824
+ unless File.directory?(dir)
1825
+ return [check(
1826
+ category: "skill_lint", name: "skill_lint", status: "pass",
1827
+ message: "No skills/ directory found at #{tilde(dir)} to lint (nothing in scope for this advisory check)"
1828
+ )]
1829
+ end
1830
+
1831
+ result = SkillLint.new(skills_dir: dir).run
1832
+
1833
+ if result.ok?
1834
+ [check(
1835
+ category: "skill_lint", name: "skill_lint", status: "pass",
1836
+ message: "skill-lint: clean (0 violations across the skills/ tree)"
1837
+ )]
1838
+ else
1839
+ by_check = result.violations.group_by { |v| v[:check] }.transform_values(&:size)
1840
+ counts = by_check.map { |check_id, n| "#{check_id}: #{n}" }.join(", ")
1841
+ details = result.violations.map { |v| "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line]}: #{v[:message]}" }
1842
+ [check(
1843
+ category: "skill_lint", name: "skill_lint", status: "pass",
1844
+ message: "skill-lint: #{result.violations.size} advisory violation(s) found (#{counts}); " \
1845
+ "informational only, never affects doctor's exit code",
1846
+ details: details
1847
+ )]
1848
+ end
1849
+ end
1850
+
1808
1851
  # --- Run all checks ---
1809
1852
 
1810
1853
  def run_checks(agent_key)
@@ -1817,6 +1860,7 @@ class Doctor
1817
1860
  all_checks += check_deprecations
1818
1861
  all_checks += check_qmd
1819
1862
  all_checks += check_done_signals
1863
+ all_checks += check_skill_lint
1820
1864
 
1821
1865
  summarize(all_checks, agent_key)
1822
1866
  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
@@ -395,10 +395,24 @@ module Bridge
395
395
  File.exist?(path)
396
396
  end
397
397
 
398
+ # True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
399
+ # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
400
+ # never counts, an empty *.md never counts, and a sentinel-only *.md never
401
+ # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
402
+ # a missing actions/ dir globs to nothing and returns false (the gate then
403
+ # reports it needs a real action file); it never raises.
404
+ def self.has_real_action?(intent_dir)
405
+ Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
406
+ File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
407
+ end
408
+ rescue StandardError
409
+ false
410
+ end
411
+
398
412
  def self.derive_stage(intent_dir)
399
413
  return "done" if stage_file_present?("#{intent_dir}/outcome.md")
400
414
  if stage_file_present?("#{intent_dir}/plan.md") &&
401
- File.directory?("#{intent_dir}/actions") &&
415
+ has_real_action?(intent_dir) &&
402
416
  stage_file_present?("#{intent_dir}/checklist.md")
403
417
  return "exec"
404
418
  end
@@ -414,7 +428,7 @@ module Bridge
414
428
  ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
415
429
  files << f if stage_file_present?("#{intent_dir}/#{f}")
416
430
  end
417
- files << "actions/" if File.directory?("#{intent_dir}/actions")
431
+ files << "actions/" if has_real_action?(intent_dir)
418
432
  files
419
433
  end
420
434
 
@@ -779,8 +793,13 @@ module Bridge
779
793
  return "Cannot start How — Why is incomplete (spec.md missing)"
780
794
  end
781
795
  when "checklist.md"
782
- unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
783
- return "Cannot complete How — plan.md or actions/ missing"
796
+ unless stage_file_present?("#{intent_dir}/plan.md")
797
+ return "Cannot complete How — plan.md missing"
798
+ end
799
+ unless has_real_action?(intent_dir)
800
+ return "Cannot complete How — actions/ has no real action file (only .gitkeep or empty). " \
801
+ "The planner must write at least one actions/ACTION_N.md before checklist.md. " \
802
+ "See skills/intent-planning."
784
803
  end
785
804
  when "outcome.md"
786
805
  checklist = "#{intent_dir}/checklist.md"
@@ -1017,11 +1036,14 @@ module Bridge
1017
1036
  return nil unless store && dir
1018
1037
  intent_dir_abs = File.expand_path("#{store}/#{dir}")
1019
1038
 
1020
- # "How reached" = the plan triplet exists. Gate by artifact presence, not the
1021
- # stage label (derive_stage returns "how" as soon as spec.md exists, before any
1022
- # plan). Code edits stay blocked until plan.md + checklist.md are both present.
1039
+ # "How reached" = plan.md + checklist.md are both present AND actions/ holds at
1040
+ # least one real action file. Gate by artifact presence, not the stage label
1041
+ # (derive_stage returns "how" as soon as spec.md exists, before any plan). Code
1042
+ # edits stay blocked until the planner has written a real action file, so an
1043
+ # empty or .gitkeep-only actions/ never opens the code gate.
1023
1044
  reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
1024
- stage_file_present?("#{intent_dir_abs}/checklist.md")
1045
+ stage_file_present?("#{intent_dir_abs}/checklist.md") &&
1046
+ has_real_action?(intent_dir_abs)
1025
1047
  return nil if reached_how
1026
1048
 
1027
1049
  file_abs = File.expand_path(file_path.to_s)
@@ -1030,9 +1052,9 @@ module Bridge
1030
1052
  return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
1031
1053
 
1032
1054
  id = intent_info["id"]
1033
- "intent #{id} has not reached How — write plan.md + checklist.md before " \
1034
- "editing project code. Run plastic-auto or plastic-intent-planning first. " \
1035
- "(blocked edit: #{file_abs})"
1055
+ "intent #{id} has not reached How — write plan.md + checklist.md and at least " \
1056
+ "one real actions/ACTION_N.md before editing project code. Run plastic-auto or " \
1057
+ "plastic-intent-planning first. (blocked edit: #{file_abs})"
1036
1058
  end
1037
1059
 
1038
1060
  # --- Solo-mode detection (intent 128) ---------------------------------------
@@ -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
@@ -296,6 +296,10 @@ class InstallerCore
296
296
  "scripts/lib/legacy_bookend_amnesty.rb" => "scripts/lib/legacy_bookend_amnesty.rb",
297
297
  "scripts/doctor.rb" => "scripts/doctor.rb",
298
298
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
299
+ "scripts/skill-lint" => "scripts/skill-lint",
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",
299
303
  }
300
304
  end
301
305