@zalom/plastic 1.0.3 → 1.1.0

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.
Files changed (98) hide show
  1. package/PLASTIC-reference.md +11 -11
  2. package/PLASTIC.md +7 -7
  3. package/README.md +47 -0
  4. package/agents/plastic-enforcer.md +3 -3
  5. package/agents/plastic-intent-curator.md +9 -10
  6. package/agents/plastic-spec-specialist.md +9 -6
  7. package/bin/plastic.js +2 -2
  8. package/package.json +1 -1
  9. package/scripts/dashboard.rb +1 -1
  10. package/scripts/doctor.rb +52 -4
  11. package/scripts/end-intent +330 -0
  12. package/scripts/hook-code-gate +1 -1
  13. package/scripts/hook-continue +1 -1
  14. package/scripts/hook-create-gate +2 -2
  15. package/scripts/hook-session-start +1 -1
  16. package/scripts/lib/bridge.rb +35 -3
  17. package/scripts/lib/installer_core.rb +8 -6
  18. package/scripts/lib/store_provisioning.rb +1 -1
  19. package/scripts/new-intent +1 -1
  20. package/scripts/{versions.rb → rollback.rb} +33 -41
  21. package/scripts/update.rb +1 -1
  22. package/skills/_active-intent-gate.md +1 -1
  23. package/skills/_decision-tables.md +50 -0
  24. package/skills/auto/SKILL.md +47 -64
  25. package/skills/auto/evals/evals.json +4 -4
  26. package/skills/auto/references/agent-architecture.md +2 -2
  27. package/skills/dashboard/SKILL.md +2 -2
  28. package/skills/doctor/SKILL.md +3 -3
  29. package/skills/install/SKILL.md +1 -1
  30. package/skills/{brainstorming → intent-brainstorming}/SKILL.md +43 -45
  31. package/skills/intent-brainstorming/evals/evals.json +67 -0
  32. package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
  33. package/skills/{continuing → intent-continuing}/SKILL.md +3 -3
  34. package/skills/{continuing → intent-continuing}/evals/evals.json +24 -24
  35. package/skills/{creating-intent → intent-creating}/SKILL.md +3 -3
  36. package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
  37. package/skills/{intent-discovery → intent-discovering}/SKILL.md +1 -1
  38. package/skills/intent-ending/SKILL.md +151 -0
  39. package/skills/intent-ending/evals/evals.json +74 -0
  40. package/skills/{executing-plan → intent-executing}/SKILL.md +75 -15
  41. package/skills/intent-executing/evals/evals.json +66 -0
  42. package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +4 -4
  43. package/skills/{linking-intents → intent-linking}/SKILL.md +1 -1
  44. package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
  45. package/skills/{lock → intent-locking}/SKILL.md +1 -1
  46. package/skills/{writing-plans → intent-planning}/SKILL.md +48 -20
  47. package/skills/intent-planning/evals/evals.json +66 -0
  48. package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
  49. package/skills/{research → intent-researching}/SKILL.md +2 -2
  50. package/skills/{research → intent-researching}/evals/evals.json +1 -1
  51. package/skills/{savepoint → intent-savepoint}/SKILL.md +1 -1
  52. package/skills/intent-speccing/SKILL.md +69 -0
  53. package/skills/intent-speccing/evals/evals.json +126 -0
  54. package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
  55. package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
  56. package/skills/intent-starting/SKILL.md +2 -2
  57. package/skills/intent-starting/evals/evals.json +2 -2
  58. package/skills/{creating-project → project-creating}/SKILL.md +2 -2
  59. package/skills/releasing/SKILL.md +18 -14
  60. package/skills/{versions → rollback}/SKILL.md +30 -20
  61. package/skills/{creating-skills → skill-creating}/SKILL.md +2 -2
  62. package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
  63. package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
  64. package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +1 -1
  65. package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
  66. package/skills/{intent-curator → store-curating}/SKILL.md +6 -7
  67. package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
  68. package/skills/{managing-index → store-indexing}/SKILL.md +6 -7
  69. package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
  70. package/skills/{add-project-store → store-provisioning}/SKILL.md +2 -2
  71. package/skills/tutorial/SKILL.md +65 -0
  72. package/skills/tutorial/evals/evals.json +186 -0
  73. package/skills/tutorial/references/track-1-guided.md +123 -0
  74. package/skills/tutorial/references/track-2-auto.md +95 -0
  75. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
  76. package/skills/update/SKILL.md +1 -1
  77. package/templates/agents.md +7 -7
  78. package/skills/brainstorming/evals/evals.json +0 -22
  79. /package/skills/{continuing → intent-continuing}/references/context-management.md +0 -0
  80. /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
  81. /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
  82. /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
  83. /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
  84. /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
  85. /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
  86. /package/skills/{savepoint → intent-savepoint}/references/context-management.md +0 -0
  87. /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
  88. /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
  89. /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
  90. /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
  91. /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
  92. /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
  93. /package/skills/{creating-skills → skill-creating}/references/skills.md +0 -0
  94. /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
  95. /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
  96. /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
  97. /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
  98. /package/skills/{managing-index → store-indexing}/references/zettelkasten-linking.md +0 -0
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # end-intent (intent 161) - the mechanical core of the single owned Done
6
+ # procedure (D2 steps 1-4). Pure with respect to worktrees, locks, and QMD:
7
+ # those stay in the plastic-intent-ending skill body (steps 0 and 5-7), never
8
+ # duplicated here. Pairs new-intent's shape: one callable script instead of
9
+ # raw one-liners repeated in skill prose (the failure mode this intent fixes).
10
+ #
11
+ # Usage:
12
+ # end-intent --store <store_path> --id <intent_id> --disposition delivered|abandoned \
13
+ # [--index <path>] [--outcome-summary <text>] [--index-note <text>] \
14
+ # [--no-commit] [--dry-run]
15
+ #
16
+ # Steps performed (in order), all idempotent:
17
+ # 1. Guard outcome.md (exit 2, authors nothing, on any failure), then stamp
18
+ # the intent file's `## Outcome` section with --outcome-summary if given.
19
+ # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
20
+ # (delivered) or `## Abandoned` (abandoned), dated today, appending
21
+ # --index-note (if given) after the date so the terminal entry stays
22
+ # the store's existing rich Completed/Abandoned shape rather than
23
+ # thinning to bare id+date.
24
+ # 3. Append the savepoint `Done` bookend (Bridge.append_terminal_savepoint).
25
+ # 4. Commit the store repo, unless --no-commit.
26
+ #
27
+ # Exit codes: 0 (ok), 1 (usage/resolution failure), 2 (outcome.md guard refused).
28
+
29
+ require "fileutils"
30
+ require "date"
31
+ require "open3"
32
+ require_relative "lib/bridge"
33
+ require_relative "lib/intent_validator"
34
+
35
+ DISPOSITIONS = %w[delivered abandoned].freeze
36
+
37
+ # The store-line separator this script must reproduce is a real em dash
38
+ # (U+2014), the existing INDEX.md convention (store files are exempt from the
39
+ # no-em-dash shipped-file rule). Built from the codepoint, not a literal byte
40
+ # in this source file, so the script's own source stays em-dash free.
41
+ EM_DASH = "\u2014"
42
+
43
+ # --- Explicit flag parsing (no eval, no global injection) -------------------
44
+
45
+ def parse_args(argv)
46
+ opts = {
47
+ store: nil, id: nil, disposition: nil, index: nil,
48
+ outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
49
+ }
50
+ i = 0
51
+ while i < argv.length
52
+ arg = argv[i]
53
+ case arg
54
+ when "--store" then opts[:store] = argv[i += 1]
55
+ when "--id" then opts[:id] = argv[i += 1]
56
+ when "--disposition" then opts[:disposition] = argv[i += 1]
57
+ when "--index" then opts[:index] = argv[i += 1]
58
+ when "--outcome-summary" then opts[:outcome_summary] = argv[i += 1]
59
+ when "--index-note" then opts[:index_note] = argv[i += 1]
60
+ when "--no-commit" then opts[:no_commit] = true
61
+ when "--dry-run" then opts[:dry_run] = true
62
+ else
63
+ usage_abort("unknown argument #{arg.inspect}")
64
+ end
65
+ i += 1
66
+ end
67
+ opts
68
+ end
69
+
70
+ def usage
71
+ "usage: end-intent --store <store_path> --id <intent_id> " \
72
+ "--disposition delivered|abandoned [--index <path>] " \
73
+ "[--outcome-summary <text>] [--index-note <text>] [--no-commit] [--dry-run]"
74
+ end
75
+
76
+ def usage_abort(message)
77
+ warn "end-intent: #{message}"
78
+ warn usage
79
+ exit 1
80
+ end
81
+
82
+ def expand(path)
83
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
84
+ end
85
+
86
+ # Resolve the single "<store>/<id>--*" directory, or abort (usage error: exit 1).
87
+ def resolve_intent_dir(store, id)
88
+ matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
89
+ usage_abort("no intent directory matches #{id}--* under #{store}") if matches.empty?
90
+ if matches.length > 1
91
+ usage_abort("ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}")
92
+ end
93
+ matches.first
94
+ end
95
+
96
+ # --- outcome.md guard (D2 step 1a) ------------------------------------------
97
+ #
98
+ # Returns nil when the guard PASSES, or a reason String to refuse with exit 2.
99
+ # Read-only: authors nothing regardless of outcome. Refuses a missing file, a
100
+ # still-placeholder file (first line is the sentinel), or a disposition that
101
+ # does not exactly equal the requested one (this is what naturally refuses a
102
+ # freshly scaffolded outcome.md, whose disposition is the literal
103
+ # "delivered|abandoned" string, not one of the two real values).
104
+ def outcome_guard_reason(intent_dir, disposition)
105
+ path = File.join(intent_dir, "outcome.md")
106
+ return "outcome.md is missing at #{path}" unless File.exist?(path)
107
+
108
+ content = File.read(path)
109
+ first_line = content.each_line.first.to_s.chomp
110
+ if first_line == Bridge::PLACEHOLDER_SENTINEL
111
+ return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
112
+ end
113
+
114
+ fm = IntentValidator.parse_frontmatter_text(content)
115
+ actual = fm.is_a?(Hash) ? fm["disposition"] : nil
116
+ unless DISPOSITIONS.include?(actual) && actual == disposition
117
+ return "outcome.md frontmatter disposition is #{actual.inspect}, expected #{disposition.inspect}"
118
+ end
119
+
120
+ nil
121
+ end
122
+
123
+ # --- intent-file `## Outcome` summary stamp (D2 step 1b) --------------------
124
+ #
125
+ # Replace the body of the intent file's `## Outcome` section with `summary`,
126
+ # leaving every other section and the frontmatter untouched. No-op (returns
127
+ # content unchanged) when summary is blank or the section is not found.
128
+ def stamp_outcome_summary(content, summary)
129
+ return content if summary.nil? || summary.to_s.strip.empty?
130
+
131
+ lines = content.lines
132
+ start = lines.index { |l| l.rstrip == "## Outcome" }
133
+ return content if start.nil?
134
+
135
+ stop = start + 1
136
+ stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
137
+
138
+ body = "#{summary.to_s.strip}\n"
139
+ replacement = (stop < lines.length) ? [body, "\n"] : [body]
140
+ (lines[0..start] + replacement + lines[stop..]).join
141
+ end
142
+
143
+ # --- INDEX.md terminal move (D2 step 2) -------------------------------------
144
+
145
+ ACTIVE_LINE_RE = /\A- \[(\S+)\s+#{Regexp.escape(EM_DASH)}\s+(.*?)\]\(([^)]+)\)/.freeze
146
+
147
+ def index_target_heading(disposition)
148
+ disposition == "delivered" ? "## Completed" : "## Abandoned"
149
+ end
150
+
151
+ # Index of the first `## ` heading at or after heading_idx + 1, or lines.length
152
+ # at EOF (the section's exclusive end bound).
153
+ def section_stop(lines, heading_idx)
154
+ stop = heading_idx + 1
155
+ stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
156
+ stop
157
+ end
158
+
159
+ def active_line_id(line)
160
+ m = line.match(ACTIVE_LINE_RE)
161
+ m && m[1]
162
+ end
163
+
164
+ def section_contains_id?(lines, heading_idx, id)
165
+ stop = section_stop(lines, heading_idx)
166
+ (heading_idx + 1...stop).any? { |i| active_line_id(lines[i]) == id }
167
+ end
168
+
169
+ # Remove the entry at entry_idx from the `## Active` section. When no real
170
+ # entries remain, install the canonical "_(none)_" placeholder (the same
171
+ # empty-state convention INDEX.md already uses).
172
+ def remove_active_entry(lines, active_start, entry_idx)
173
+ new_lines = lines.dup
174
+ new_lines.delete_at(entry_idx)
175
+ stop = section_stop(new_lines, active_start)
176
+ remaining = (active_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
177
+ if remaining.empty?
178
+ # Keep the blank-line separator before the next heading (if any); only
179
+ # collapse the section body to the placeholder, never the spacing.
180
+ placeholder = (stop < new_lines.length) ? ["_(none)_\n", "\n"] : ["_(none)_\n"]
181
+ new_lines[(active_start + 1)...stop] = placeholder
182
+ end
183
+ new_lines
184
+ end
185
+
186
+ # Insert new_entry into the target terminal section: replace the "_(none)_"
187
+ # placeholder if that is the ONLY non-blank content the section holds (a
188
+ # trailing blank line before the next heading is common and must not
189
+ # defeat this check), otherwise prepend (newest-first, matching the
190
+ # existing Completed/Abandoned convention).
191
+ def insert_terminal_entry(lines, target_start, new_entry)
192
+ new_lines = lines.dup
193
+ stop = section_stop(new_lines, target_start)
194
+ body_range = (target_start + 1...stop)
195
+ placeholder_idx = body_range.find { |i| new_lines[i].strip == "_(none)_" }
196
+ non_blank = body_range.reject { |i| new_lines[i].strip.empty? }
197
+ if placeholder_idx && non_blank == [placeholder_idx]
198
+ new_lines[placeholder_idx] = new_entry
199
+ else
200
+ new_lines.insert(target_start + 1, new_entry)
201
+ end
202
+ new_lines
203
+ end
204
+
205
+ # Move the intent's `## Active` line into the terminal section. Returns the
206
+ # new INDEX.md content, or the original content unchanged when there is
207
+ # nothing to do (idempotent: a second run finds no Active entry and the
208
+ # terminal section already carries it, so it is a clean no-op). `index_note`
209
+ # (D2 "rich entry", review F3), when given, is appended verbatim after the
210
+ # date with a single leading space: content authoring (mode/tier, what
211
+ # shipped, suite result, and so on) stays with the caller, this only places
212
+ # the text where the store's existing Completed/Abandoned lines carry it.
213
+ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
214
+ target_heading = index_target_heading(disposition)
215
+ lines = content.lines
216
+
217
+ active_start = lines.index { |l| l.rstrip == "## Active" }
218
+ target_start = lines.index { |l| l.rstrip == target_heading }
219
+ return content if active_start.nil? || target_start.nil?
220
+
221
+ already_in_target = section_contains_id?(lines, target_start, id)
222
+ active_stop = section_stop(lines, active_start)
223
+ entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
224
+
225
+ return content if entry_idx.nil? # already moved (or never present): no-op either way
226
+
227
+ m = lines[entry_idx].match(ACTIVE_LINE_RE)
228
+ title, link = m[2], m[3]
229
+ note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
230
+ new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
231
+
232
+ lines = remove_active_entry(lines, active_start, entry_idx)
233
+ target_start = lines.index { |l| l.rstrip == target_heading }
234
+ lines = insert_terminal_entry(lines, target_start, new_entry) unless already_in_target
235
+
236
+ lines.join
237
+ end
238
+
239
+ # --- store auto-commit (D2 step 4) ------------------------------------------
240
+
241
+ def git_toplevel(dir)
242
+ out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
243
+ return nil unless status.success?
244
+ top = out.strip
245
+ top.empty? ? nil : top
246
+ end
247
+
248
+ # Best-effort store commit: never raises, never blocks the mechanical close.
249
+ # Returns true when a commit was created, false otherwise (not a git repo,
250
+ # nothing to commit, or the commit failed). Pins a local committer identity so
251
+ # the commit succeeds even with no ambient git config (hermetic).
252
+ def store_commit(store, id, disposition)
253
+ root = git_toplevel(store)
254
+ return false if root.nil?
255
+
256
+ Open3.capture3("git", "-C", root, "add", "-A")
257
+ _out, _err, status = Open3.capture3(
258
+ "git", "-C", root,
259
+ "-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
260
+ "commit", "--quiet", "-m", "chore: complete intent #{id} (#{disposition})"
261
+ )
262
+ status.success?
263
+ end
264
+
265
+ # --- main --------------------------------------------------------------------
266
+
267
+ def main(argv)
268
+ opts = parse_args(argv)
269
+ usage_abort("--store is required") if opts[:store].nil? || opts[:store].empty?
270
+ usage_abort("--id is required") if opts[:id].nil? || opts[:id].empty?
271
+ unless DISPOSITIONS.include?(opts[:disposition])
272
+ usage_abort("--disposition must be one of #{DISPOSITIONS.join('|')}")
273
+ end
274
+
275
+ store = expand(opts[:store])
276
+ usage_abort("store dir does not exist: #{store}") unless Dir.exist?(store)
277
+
278
+ id = opts[:id]
279
+ disposition = opts[:disposition]
280
+ intent_dir = resolve_intent_dir(store, id)
281
+ index_path = opts[:index] ? expand(opts[:index]) : File.join(File.dirname(store), "INDEX.md")
282
+ today = Date.today.iso8601
283
+
284
+ reason = outcome_guard_reason(intent_dir, disposition)
285
+ if reason
286
+ warn "end-intent: #{reason}"
287
+ exit 2
288
+ end
289
+
290
+ if opts[:dry_run]
291
+ puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
292
+ puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
293
+ puts " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
294
+ puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
295
+ puts " would append savepoint bookend: Done #{disposition}"
296
+ puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
297
+ exit 0
298
+ end
299
+
300
+ # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
301
+ if opts[:outcome_summary]
302
+ intent_file = Bridge.intent_file(intent_dir)
303
+ if File.exist?(intent_file)
304
+ original = File.read(intent_file)
305
+ updated = stamp_outcome_summary(original, opts[:outcome_summary])
306
+ File.write(intent_file, updated) if updated != original
307
+ end
308
+ end
309
+
310
+ # 2. INDEX.md terminal move.
311
+ if File.exist?(index_path)
312
+ original_index = File.read(index_path)
313
+ updated_index = move_index_to_terminal(original_index, id, disposition, today: today,
314
+ index_note: opts[:index_note])
315
+ File.write(index_path, updated_index) if updated_index != original_index
316
+ else
317
+ warn "end-intent: INDEX.md not found at #{index_path}, skipping the terminal move"
318
+ end
319
+
320
+ # 3. Savepoint Done bookend (idempotent).
321
+ Bridge.append_terminal_savepoint(intent_dir, disposition)
322
+
323
+ # 4. Store auto-commit, unless --no-commit.
324
+ store_commit(store, id, disposition) unless opts[:no_commit]
325
+
326
+ puts intent_dir
327
+ exit 0
328
+ end
329
+
330
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -39,7 +39,7 @@ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
39
39
  end
40
40
 
41
41
  # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
42
- bridge_data = Bridge.discover_bridge(session: session, cwd: file_path)
42
+ bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
43
43
  exit 0 unless bridge_data
44
44
 
45
45
  reason = Bridge.code_gate_decision(bridge_data, file_path) ||
@@ -19,7 +19,7 @@ cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
19
19
  exit 0 unless status.success? && !cockpit.strip.empty?
20
20
 
21
21
  context = cockpit.rstrip +
22
- "\n\nFollow the plastic-continuing skill workflow to resume: " \
22
+ "\n\nFollow the plastic-intent-continuing skill workflow to resume: " \
23
23
  "if active intents exist, read their state and resume; otherwise offer the " \
24
24
  "Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
25
25
 
@@ -67,7 +67,7 @@ result =
67
67
  # the validator.
68
68
  unless File.exist?(abs)
69
69
  block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
70
- "create intents via new-intent / plastic-creating-intent.")
70
+ "create intents via new-intent / plastic-intent-creating.")
71
71
  end
72
72
  current = File.read(abs)
73
73
  unless current.include?(old_string)
@@ -91,5 +91,5 @@ exit 0 if result[:ok]
91
91
 
92
92
  $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
93
93
  result[:errors].each { |e| $stderr.puts " #{e}" }
94
- $stderr.puts "Create intents via new-intent / plastic-creating-intent; do not hand-author them."
94
+ $stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
95
95
  exit 2
@@ -201,7 +201,7 @@ parts = []
201
201
 
202
202
  # Boot banner first: every session start surfaces that Plastic loaded, with the
203
203
  # version (clean) or the first failing core check (degraded). Owned by the hook
204
- # so it runs by construction — the plastic-continuing skill no longer does this.
204
+ # so it runs by construction — the plastic-intent-continuing skill no longer does this.
205
205
  parts << core_banner
206
206
  parts << ""
207
207
 
@@ -165,6 +165,18 @@ module Bridge
165
165
  -1
166
166
  end
167
167
 
168
+ # The provisioned code worktree dir (<repo>/.claude/worktrees/{id}--{slug})
169
+ # that contains file_path, or nil (intent 168). Structural and bridge-free:
170
+ # Worktree.provision builds exactly this layout, so a file under it is
171
+ # "worktree-scoped" and only that intent may gate it. The {id}--{slug} shape
172
+ # (a `--` in the dir name) is required, so a stray .claude/worktrees/README
173
+ # is not worktree-scoped and returns nil.
174
+ def self.enclosing_worktree_dir(file_path)
175
+ return nil if blank?(file_path)
176
+ m = File.expand_path(file_path).match(%r{\A(.*/\.claude/worktrees/[^/]+--[^/]+)(?:/|\z)})
177
+ m && m[1]
178
+ end
179
+
168
180
  # Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
169
181
  # legacy-keyed files), keep only valid bridges, filter to the caller's own
170
182
  # session when it has one, prefer auto-armed, then disambiguate by cwd tier
@@ -172,7 +184,7 @@ module Bridge
172
184
  # path: a session now legitimately owns several bridges (one per concurrent
173
185
  # intent), so filename lookup alone cannot pick the right one; cwd must
174
186
  # decide (intent 131).
175
- def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
187
+ def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir, edited_path: nil)
176
188
  candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
177
189
  parsed = candidates.filter_map do |f|
178
190
  data = (JSON.parse(File.read(f)) rescue nil)
@@ -181,6 +193,26 @@ module Bridge
181
193
  end
182
194
  return nil if parsed.empty?
183
195
 
196
+ # Worktree-membership-first (intent 168). hook-code-gate passes the edited
197
+ # file as edited_path; when that file lies inside a provisioned code worktree
198
+ # (<repo>/.claude/worktrees/{id}--{slug}), only that worktree's owning intent
199
+ # may gate the write, BEFORE the intent-90 per-session filter below. Resolve
200
+ # the candidate whose worktree.code owns that dir (newest mtime on a tie), or
201
+ # nil when none owns it, so a session-keyed guided bridge can never claim a
202
+ # write located inside a sibling intent's worktree. No edited_path (every
203
+ # other caller) or a non-worktree path skips this and runs the pipeline
204
+ # unchanged (intents 90/52/131 preserved).
205
+ unless blank?(edited_path)
206
+ wt_dir = enclosing_worktree_dir(edited_path)
207
+ if wt_dir
208
+ owners = parsed.select do |c|
209
+ code = c[:data].dig("worktree", "code")
210
+ !blank?(code) && File.expand_path(code) == wt_dir
211
+ end
212
+ return owners.max_by { |c| c[:mtime] }&.fetch(:data)
213
+ end
214
+ end
215
+
184
216
  has_session = !blank?(session)
185
217
 
186
218
  # Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
@@ -412,7 +444,7 @@ module Bridge
412
444
  NEXT_HINTS = {
413
445
  "why" => "write spec.md",
414
446
  "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
415
- "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
447
+ "exec" => "How complete. Invoke plastic-auto or plastic-intent-executing to execute, or work through the checklist manually.",
416
448
  "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
417
449
  }.freeze
418
450
 
@@ -918,7 +950,7 @@ module Bridge
918
950
 
919
951
  id = intent_info["id"]
920
952
  "intent #{id} has not reached How — write plan.md + checklist.md before " \
921
- "editing project code. Run plastic-auto or plastic-writing-plans first. " \
953
+ "editing project code. Run plastic-auto or plastic-intent-planning first. " \
922
954
  "(blocked edit: #{file_abs})"
923
955
  end
924
956
 
@@ -10,7 +10,7 @@ require_relative "hook_registry"
10
10
  require_relative "agent_models"
11
11
 
12
12
  # Shared installer machinery, instantiable with injected package root / store / agent
13
- # map so the verb scripts (install/update/uninstall/versions) and their tests can run
13
+ # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
14
14
  # hermetically (no eval, no global-constant rewriting). Mirrors the DI recipe proven in
15
15
  # doctor.rb / install.rb (intents 30a, 30a1). Library only — no CLI, no $PROGRAM_NAME guard.
16
16
  class InstallerCore
@@ -54,7 +54,7 @@ class InstallerCore
54
54
  STABILITY[ch] || 2
55
55
  end
56
56
 
57
- # --- Semver (§11) — parse/compare, shared by update + versions ---
57
+ # --- Semver (§11) — parse/compare, shared by update + rollback ---
58
58
 
59
59
  def semver_parse(version)
60
60
  m = /\A(\d+)\.(\d+)\.(\d+)(?:-(.+))?\z/.match(version.to_s.strip)
@@ -286,7 +286,7 @@ class InstallerCore
286
286
  "scripts/install.rb" => "scripts/install.rb",
287
287
  "scripts/update.rb" => "scripts/update.rb",
288
288
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
289
- "scripts/versions.rb" => "scripts/versions.rb",
289
+ "scripts/rollback.rb" => "scripts/rollback.rb",
290
290
  "scripts/doctor.rb" => "scripts/doctor.rb",
291
291
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
292
292
  }
@@ -471,8 +471,10 @@ class InstallerCore
471
471
  end
472
472
 
473
473
  # Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
474
- # directory name the only personal-skill namespacing Claude Code supports).
475
- # The non-skill `_active-intent-gate.md` is relocated to ~/.plastic/ instead.
474
+ # directory name -- the only personal-skill namespacing Claude Code supports).
475
+ # Any top-level underscore-prefixed markdown fragment (e.g. `_active-intent-gate.md`,
476
+ # `_decision-tables.md`) is a shared non-skill fragment and relocates to ~/.plastic/
477
+ # instead, so every skill can read it from one shared location.
476
478
  def install_skills_flat(skills_source, skills_root)
477
479
  installed = []
478
480
  FileUtils.mkdir_p(skills_root)
@@ -481,7 +483,7 @@ class InstallerCore
481
483
  src = File.join(skills_source, entry)
482
484
  if File.directory?(src)
483
485
  installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
484
- elsif entry == "_active-intent-gate.md"
486
+ elsif entry.start_with?("_") && entry.end_with?(".md")
485
487
  FileUtils.mkdir_p(plastic_home)
486
488
  dest = File.join(plastic_home, entry)
487
489
  FileUtils.cp(src, dest)
@@ -14,7 +14,7 @@ require "yaml"
14
14
  # `project.yml` (from templates/project.yml). Re-running never clobbers existing
15
15
  # files. The logic was migrated here from the orphaned
16
16
  # `InstallerCore#bootstrap_project_store` so there is one definition that the
17
- # `provision-project-store` CLI, the `plastic-add-project-store` skill, the
17
+ # `provision-project-store` CLI, the `plastic-store-provisioning` skill, the
18
18
  # doctor fix hint, and the project skills all consult.
19
19
  #
20
20
  # Pure filesystem and dependency-injected: `provision` accepts an injectable
@@ -13,7 +13,7 @@
13
13
  # IntentValidator (exit non-zero if not born complete).
14
14
  #
15
15
  # It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
16
- # responsibilities (see plastic-creating-intent).
16
+ # responsibilities (see plastic-intent-creating).
17
17
  #
18
18
  # Usage:
19
19
  # new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
@@ -2,20 +2,24 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # Plastic — `versions` verb. Runs via `npx @zalom/plastic versions` (bin/plastic.js) or directly.
6
- # Usage: ruby scripts/versions.rb [--downgrade [--version <v>] | --upgrade] [--help]
5
+ # Plastic — `rollback` verb. Runs via `npx @zalom/plastic rollback` (bin/plastic.js) or directly.
6
+ # Usage: ruby scripts/rollback.rb [--version <v> [--downgrade | --upgrade]] [--help]
7
7
  #
8
8
  # Local time-machine over the append-only versions.json ledger. Navigates ONLY versions you
9
9
  # have actually run (the ledger), never npm's full list — fetching brand-new versions is
10
10
  # `update`'s job. Lets you roll back to a known-good build after a bad release.
11
- # - no flag print the ledger table (and offer to continue a rollback)
12
- # - --downgrade step back one version in your history
13
- # - --downgrade --version V jump to a specific previously-run version
14
- # - --upgrade step forward one version (after a rollback)
11
+ # - no flag read-only: print the version-history table, never switch
12
+ # - --version V switch to a specific previously-run version (direction derived)
13
+ # - --downgrade --version V synonym of --version V (direction flag is descriptive only)
14
+ # - --upgrade --version V synonym of --version V (direction flag is descriptive only)
15
+ # - --downgrade / --upgrade alone no target given: error, no switch
16
+ #
17
+ # Switching always requires an explicit --version V. A rollback tool whose job is safety
18
+ # during a bad release must never switch versions without a named target.
15
19
 
16
20
  require_relative "lib/installer_core"
17
21
 
18
- class Versions < InstallerCore
22
+ class Rollback < InstallerCore
19
23
  PKG = "@zalom/plastic"
20
24
 
21
25
  def cli(argv = ARGV)
@@ -32,31 +36,24 @@ class Versions < InstallerCore
32
36
 
33
37
  timeline = version_timeline(ledger)
34
38
  current = installed_version || timeline.last
39
+ explicit = flag_value(argv, "--version")
35
40
 
36
- if argv.include?("--downgrade")
37
- explicit = flag_value(argv, "--version")
38
- target = explicit || step_target(timeline, current, :back)
39
- return no_target("No earlier version in your history.") unless target
40
- unless timeline.include?(target)
41
- warn "#{target} is not in your version history. Choose one of: #{timeline.join(", ")}"
41
+ if explicit
42
+ unless timeline.include?(explicit)
43
+ warn "#{explicit} is not in your version history. Choose one of: #{timeline.join(", ")}"
42
44
  return 1
43
45
  end
44
- return switch_to(target, current)
45
- elsif argv.include?("--upgrade")
46
- target = step_target(timeline, current, :forward)
47
- return no_target("No later version in your history. Use `update` for new releases.") unless target
48
- return switch_to(target, current)
46
+ return switch_to(explicit, current)
49
47
  end
50
48
 
51
- # No flag: show the table, and detect an in-progress rollback.
52
- print_table(ledger, current)
53
- if ledger.last && ledger.last["action"] == "downgrade"
54
- prev = step_target(timeline, current, :back)
55
- if prev && $stdin.tty?
56
- print "\nYou recently rolled back. Go back further to #{prev}? [y/N]: "
57
- return switch_to(prev, current) if ($stdin.gets&.strip || "").downcase.start_with?("y")
58
- end
49
+ if argv.include?("--downgrade") || argv.include?("--upgrade")
50
+ warn "An explicit target is required: pass --version <v>. Run `rollback` with no " \
51
+ "flags to see your version history and pick a target."
52
+ return 1
59
53
  end
54
+
55
+ # No flags at all: read-only. Print the table and stop; never switch, never prompt.
56
+ print_table(ledger, current)
60
57
  0
61
58
  end
62
59
 
@@ -93,11 +90,6 @@ class Versions < InstallerCore
93
90
  argv[i + 1]
94
91
  end
95
92
 
96
- def no_target(msg)
97
- puts msg
98
- 0
99
- end
100
-
101
93
  def switch_to(target, current)
102
94
  action = action_for(target, current)
103
95
  puts "#{action == "downgrade" ? "\u{23ea}" : "\u{23e9}"} #{current} \u{2192} #{target} (#{action})"
@@ -119,23 +111,23 @@ class Versions < InstallerCore
119
111
  def show_help
120
112
  puts <<~HELP
121
113
 
122
- plastic versionsmanage and roll back your local Plastic versions
114
+ plastic rollbackread installed and available versions, and switch on request
123
115
 
124
116
  Usage:
125
- npx @zalom/plastic versions [options]
117
+ npx @zalom/plastic rollback [options]
126
118
 
127
119
  Options:
128
- (none) Show the version-history table
129
- --downgrade Roll back one version in your history
130
- --downgrade --version V Roll back to a specific previously-run version
131
- --upgrade Step forward one version in your history
132
- -h, --help Show this help
120
+ (none) Show the version-history table (read-only, no switch)
121
+ --version V Switch to version V (direction derived automatically)
122
+ --downgrade --version V Synonym of --version V
123
+ --upgrade --version V Synonym of --version V
124
+ -h, --help Show this help
133
125
 
134
- Navigates only versions you have already run (the append-only ledger). To move to a
135
- brand-new release, use `update`.
126
+ Switching always requires an explicit --version V. Navigates only versions you have
127
+ already run (the append-only ledger). To move to a brand-new release, use `update`.
136
128
 
137
129
  HELP
138
130
  end
139
131
  end
140
132
 
141
- exit(Versions.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__
133
+ exit(Rollback.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__
package/scripts/update.rb CHANGED
@@ -164,7 +164,7 @@ class Update < InstallerCore
164
164
  Behaviour:
165
165
  No flag advances to the next version on your current channel. Switching toward a
166
166
  more stable channel is frictionless; switching toward bleeding edge is confirmed.
167
- Use `versions` to roll back to a previously-installed version.
167
+ Use `rollback` to roll back to a previously-installed version.
168
168
 
169
169
  HELP
170
170
  end
@@ -13,7 +13,7 @@ Before proceeding, resolve the active intent:
13
13
  - Look under `## Active` for intent entries
14
14
  - If exactly one active intent → use it
15
15
  - If multiple active intents → ask user which one
16
- - If no active intent → refuse: "No active intent. Create one first with /plastic-creating-intent"
16
+ - If no active intent → refuse: "No active intent. Create one first with /plastic-intent-creating"
17
17
 
18
18
  3. **Resolve paths:**
19
19
  - Intent directory: `{store}/store/{id}--{slug}/`