@zalom/plastic 1.0.2 → 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 (105) hide show
  1. package/PLASTIC-reference.md +11 -11
  2. package/PLASTIC.md +37 -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-intent-discovery.md +1 -0
  7. package/agents/plastic-spec-specialist.md +9 -6
  8. package/bin/plastic.js +2 -2
  9. package/package.json +1 -1
  10. package/scripts/dashboard.rb +2 -2
  11. package/scripts/doctor.rb +55 -7
  12. package/scripts/end-intent +330 -0
  13. package/scripts/hook-code-gate +1 -1
  14. package/scripts/hook-continue +1 -1
  15. package/scripts/hook-create-gate +2 -2
  16. package/scripts/hook-session-start +1 -1
  17. package/scripts/lib/bridge.rb +46 -13
  18. package/scripts/lib/dashboard_banner.rb +1 -1
  19. package/scripts/lib/installer_core.rb +8 -6
  20. package/scripts/lib/lock.rb +3 -3
  21. package/scripts/lib/store_provisioning.rb +1 -1
  22. package/scripts/new-intent +1 -1
  23. package/scripts/{versions.rb → rollback.rb} +33 -41
  24. package/scripts/update.rb +1 -1
  25. package/skills/_active-intent-gate.md +1 -1
  26. package/skills/_decision-tables.md +50 -0
  27. package/skills/auto/SKILL.md +48 -64
  28. package/skills/auto/evals/evals.json +4 -4
  29. package/skills/auto/references/agent-architecture.md +2 -2
  30. package/skills/auto/references/agent-report-contract.md +3 -0
  31. package/skills/dashboard/SKILL.md +3 -2
  32. package/skills/doctor/SKILL.md +4 -3
  33. package/skills/humanizer/SKILL.md +1 -0
  34. package/skills/install/SKILL.md +2 -1
  35. package/skills/{brainstorming → intent-brainstorming}/SKILL.md +44 -45
  36. package/skills/intent-brainstorming/evals/evals.json +67 -0
  37. package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
  38. package/skills/{continuing → intent-continuing}/SKILL.md +4 -3
  39. package/skills/{continuing → intent-continuing}/evals/evals.json +24 -24
  40. package/skills/{creating-intent → intent-creating}/SKILL.md +4 -3
  41. package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
  42. package/skills/{intent-discovery → intent-discovering}/SKILL.md +3 -1
  43. package/skills/intent-ending/SKILL.md +151 -0
  44. package/skills/intent-ending/evals/evals.json +74 -0
  45. package/skills/{executing-plan → intent-executing}/SKILL.md +76 -15
  46. package/skills/intent-executing/evals/evals.json +66 -0
  47. package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +5 -4
  48. package/skills/{linking-intents → intent-linking}/SKILL.md +2 -1
  49. package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
  50. package/skills/{lock → intent-locking}/SKILL.md +2 -1
  51. package/skills/{writing-plans → intent-planning}/SKILL.md +49 -20
  52. package/skills/intent-planning/evals/evals.json +66 -0
  53. package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
  54. package/skills/{research → intent-researching}/SKILL.md +5 -2
  55. package/skills/{research → intent-researching}/evals/evals.json +1 -1
  56. package/skills/{savepoint → intent-savepoint}/SKILL.md +2 -1
  57. package/skills/intent-speccing/SKILL.md +69 -0
  58. package/skills/intent-speccing/evals/evals.json +126 -0
  59. package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
  60. package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
  61. package/skills/intent-starting/SKILL.md +3 -2
  62. package/skills/intent-starting/evals/evals.json +2 -2
  63. package/skills/{creating-project → project-creating}/SKILL.md +3 -2
  64. package/skills/releasing/SKILL.md +19 -14
  65. package/skills/roadmap/SKILL.md +1 -0
  66. package/skills/{versions → rollback}/SKILL.md +31 -20
  67. package/skills/{creating-skills → skill-creating}/SKILL.md +3 -2
  68. package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
  69. package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
  70. package/skills/{creating-skills → skill-creating}/references/skills.md +1 -0
  71. package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +2 -1
  72. package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
  73. package/skills/{intent-curator → store-curating}/SKILL.md +7 -7
  74. package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
  75. package/skills/{managing-index → store-indexing}/SKILL.md +7 -7
  76. package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
  77. package/skills/{add-project-store → store-provisioning}/SKILL.md +3 -2
  78. package/skills/tutorial/SKILL.md +65 -0
  79. package/skills/tutorial/evals/evals.json +186 -0
  80. package/skills/tutorial/references/track-1-guided.md +123 -0
  81. package/skills/tutorial/references/track-2-auto.md +95 -0
  82. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
  83. package/skills/uninstall/SKILL.md +1 -0
  84. package/skills/update/SKILL.md +2 -1
  85. package/templates/agents.md +7 -7
  86. package/skills/brainstorming/evals/evals.json +0 -22
  87. /package/skills/{continuing → intent-continuing}/references/context-management.md +0 -0
  88. /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
  89. /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
  90. /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
  91. /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
  92. /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
  93. /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
  94. /package/skills/{savepoint → intent-savepoint}/references/context-management.md +0 -0
  95. /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
  96. /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
  97. /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
  98. /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
  99. /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
  100. /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
  101. /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
  102. /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
  103. /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
  104. /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
  105. /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
 
@@ -741,17 +773,18 @@ module Bridge
741
773
  data["lock"] = lock_cache(lock_data)
742
774
  when :held
743
775
  raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
744
- "session #{lock_data && lock_data['owner_session']}; run /plastic-lock status"
776
+ "session #{lock_data && lock_data['owner_session']}; run /plastic-doctor " \
777
+ "check the lock status"
745
778
  when :stale
746
779
  raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
747
- "(owner #{lock_data && lock_data['owner_session']}); run /plastic-lock " \
748
- "reclaim to take it over with an audit"
780
+ "(owner #{lock_data && lock_data['owner_session']}); run /plastic-doctor " \
781
+ "reclaim the lock to take it over with an audit"
749
782
  when :excluded
750
783
  raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
751
- "intent #{intent_id}; run /plastic-lock status"
784
+ "intent #{intent_id}; run /plastic-doctor check the lock status"
752
785
  when :corrupt
753
786
  raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
754
- "run /plastic-lock fix"
787
+ "run /plastic-doctor fix the lock"
755
788
  end
756
789
 
757
790
  # Provision the per-intent worktrees (mandatory code worktree for project
@@ -861,7 +894,7 @@ module Bridge
861
894
  end
862
895
  return { "status" => "stale", "owner" => lock["owner_session"],
863
896
  "actions" => actions, "session" => key,
864
- "hint" => "run /plastic-lock reclaim to take over with an audit" }
897
+ "hint" => "run /plastic-doctor reclaim the lock to take over with an audit" }
865
898
  end
866
899
 
867
900
  if lock
@@ -917,7 +950,7 @@ module Bridge
917
950
 
918
951
  id = intent_info["id"]
919
952
  "intent #{id} has not reached How — write plan.md + checklist.md before " \
920
- "editing project code. Run plastic-auto or plastic-writing-plans first. " \
953
+ "editing project code. Run plastic-auto or plastic-intent-planning first. " \
921
954
  "(blocked edit: #{file_abs})"
922
955
  end
923
956
 
@@ -1002,16 +1035,16 @@ module Bridge
1002
1035
  "#{lock['owner_session']}. Back off; if you are the owner's " \
1003
1036
  "subagent, the owner must run: plastic-lock delegate " \
1004
1037
  "--intent-dir #{target_dir} --session <your-session-id>. " \
1005
- "Inspect with /plastic-lock status"
1038
+ "Inspect with /plastic-doctor check the lock status"
1006
1039
  end
1007
1040
  return solo_allow(id, "stale delivery lock") if solo
1008
1041
  return "intent #{id} has a stale delivery lock (owner " \
1009
- "#{lock['owner_session']}); run /plastic-lock reclaim to take " \
1010
- "it over, or /plastic-lock fix"
1042
+ "#{lock['owner_session']}); run /plastic-doctor reclaim the lock to " \
1043
+ "take it over, or /plastic-doctor fix the lock"
1011
1044
  end
1012
1045
  if Lock.corrupt?(target_dir)
1013
1046
  return solo_allow(id, "unreadable delivery.lock") if solo
1014
- return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
1047
+ return "delivery.lock for intent #{id} is unreadable; run /plastic-doctor fix the lock"
1015
1048
  end
1016
1049
  return solo_allow(id, "no delivery lock") if solo
1017
1050
  "no delivery lock held for intent #{id}; run /plastic-intent-starting " \
@@ -20,7 +20,7 @@ module DashboardBanner
20
20
  return nil unless counts.is_a?(Hash)
21
21
  active = counts["active"].to_i
22
22
  future = counts["future"].to_i
23
- line = "Plastic: #{active} active · #{future} next · run /plastic-dashboard to see the board"
23
+ line = "Plastic: #{active} active · #{future} next · say \"show the dashboard\" to see the board"
24
24
  nbt = next_big_thing_id(payload)
25
25
  line += " · next big thing: #{nbt}" if nbt
26
26
  line
@@ -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)
@@ -368,8 +368,8 @@ module Claim
368
368
  holder = data && data["owner_session"]
369
369
  since = data && data["acquired_at"]
370
370
  "artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
371
- "it. Back off or run /plastic-lock status. If you are a distinct delegate, the " \
372
- "owner must register you: plastic-lock delegate --intent-dir #{intent_dir} " \
373
- "--session <your-session-id>"
371
+ "it. Back off or run /plastic-doctor check the lock status. If you are a distinct " \
372
+ "delegate, the owner must register you: plastic-lock delegate --intent-dir " \
373
+ "#{intent_dir} --session <your-session-id>"
374
374
  end
375
375
  end
@@ -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> \