@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
package/PLASTIC-reference.md
CHANGED
|
@@ -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 -
|
|
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
|
|
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
|
|
137
|
-
|
|
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
|
|
429
|
-
| How → Exec | Checklist has items |
|
|
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
|
|
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
|
|
35
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
18
|
-
- S/M: `
|
|
19
|
-
|
|
20
|
-
|
|
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`
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
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 `
|
|
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
|
-
-
|
|
52
|
-
|
|
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
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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")
|
|
783
|
-
return "Cannot complete How — plan.md
|
|
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" =
|
|
1021
|
-
#
|
|
1022
|
-
#
|
|
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
|
|
1034
|
-
"editing project code. Run plastic-auto or
|
|
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
|
|