@zalom/plastic 1.0.0-alpha.15 → 1.0.0-alpha.17

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 (40) hide show
  1. package/PLASTIC.md +18 -18
  2. package/README.md +13 -6
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/deprecations.yml +6 -5
  6. package/package.json +1 -1
  7. package/scripts/dashboard.rb +480 -0
  8. package/scripts/doctor.rb +37 -30
  9. package/scripts/hook-auto-arm +3 -3
  10. package/scripts/hook-continue +1 -1
  11. package/scripts/hook-gate-check +3 -3
  12. package/scripts/hook-session-start +1 -1
  13. package/scripts/install.rb +175 -119
  14. package/scripts/lib/bridge.rb +1 -1
  15. package/scripts/migrate-to-global +1 -1
  16. package/skills/_active-intent-gate.md +1 -1
  17. package/skills/auto/SKILL.md +18 -6
  18. package/skills/auto/evals/evals.json +17 -17
  19. package/skills/auto/references/agent-architecture.md +1 -1
  20. package/skills/brainstorming/SKILL.md +8 -8
  21. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  22. package/skills/continuing/SKILL.md +16 -4
  23. package/skills/creating-intent/SKILL.md +1 -1
  24. package/skills/creating-project/SKILL.md +4 -4
  25. package/skills/dashboard/SKILL.md +92 -0
  26. package/skills/doctor/SKILL.md +4 -4
  27. package/skills/evaluating-skills/SKILL.md +1 -1
  28. package/skills/executing-plan/SKILL.md +1 -1
  29. package/skills/install/SKILL.md +32 -8
  30. package/skills/intent-curator/SKILL.md +3 -3
  31. package/skills/linking-intents/SKILL.md +1 -1
  32. package/skills/managing-index/SKILL.md +1 -1
  33. package/skills/releasing/SKILL.md +3 -3
  34. package/skills/research/SKILL.md +2 -2
  35. package/skills/savepoint/SKILL.md +1 -1
  36. package/skills/uninstall/SKILL.md +39 -28
  37. package/skills/update/SKILL.md +2 -2
  38. package/skills/writing-instructions/SKILL.md +2 -2
  39. package/skills/writing-plans/SKILL.md +5 -5
  40. package/templates/agents.md +7 -7
package/scripts/doctor.rb CHANGED
@@ -12,6 +12,7 @@
12
12
  require "json"
13
13
  require "yaml"
14
14
  require "time"
15
+ require "date"
15
16
 
16
17
  PLASTIC_HOME = File.join(Dir.home, ".plastic")
17
18
 
@@ -150,21 +151,31 @@ def parse_frontmatter(path)
150
151
  parts = content.split("---", 3)
151
152
  return nil if parts.length < 3
152
153
 
153
- YAML.safe_load(parts[1]) || {}
154
+ # created: dates parse as Date objects, which safe_load rejects by default —
155
+ # permit Date/Time so valid frontmatter isn't misreported as missing.
156
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
154
157
  rescue
155
158
  nil
156
159
  end
157
160
 
158
161
  # --- Collect all intent directories (global + project stores) ---
159
162
 
163
+ # Child directories of a store, excluding dotfiles/dot-directories
164
+ # (e.g. .obsidian, .git) which are tooling artifacts, not intents.
165
+ def store_intent_dirs(store)
166
+ Dir.children(store).reject { |e| e.start_with?(".") }.select do |e|
167
+ File.directory?(File.join(store, e))
168
+ end
169
+ end
170
+
160
171
  def all_intent_dirs
161
172
  dirs = []
162
173
 
163
174
  global_store = File.join(PLASTIC_HOME, "store")
164
175
  if File.directory?(global_store)
165
- Dir.children(global_store).each do |entry|
176
+ store_intent_dirs(global_store).each do |entry|
166
177
  full = File.join(global_store, entry)
167
- dirs << { path: full, name: entry, scope: "global" } if File.directory?(full)
178
+ dirs << { path: full, name: entry, scope: "global" }
168
179
  end
169
180
  end
170
181
 
@@ -174,9 +185,9 @@ def all_intent_dirs
174
185
  project_store = File.join(projects_root, project, "store")
175
186
  next unless File.directory?(project_store)
176
187
 
177
- Dir.children(project_store).each do |entry|
188
+ store_intent_dirs(project_store).each do |entry|
178
189
  full = File.join(project_store, entry)
179
- dirs << { path: full, name: entry, scope: "project:#{project}" } if File.directory?(full)
190
+ dirs << { path: full, name: entry, scope: "project:#{project}" }
180
191
  end
181
192
  end
182
193
  end
@@ -227,7 +238,7 @@ def check_global_store
227
238
  # orphaned_intents — directories in store/ not referenced in INDEX.md
228
239
  store_dir = File.join(PLASTIC_HOME, "store")
229
240
  if File.directory?(store_dir)
230
- intent_dirs = Dir.children(store_dir).select { |e| File.directory?(File.join(store_dir, e)) }
241
+ intent_dirs = store_intent_dirs(store_dir)
231
242
  orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
232
243
 
233
244
  if orphans.empty?
@@ -459,22 +470,30 @@ def check_claude_registration(agent_dir)
459
470
  end
460
471
  end
461
472
 
462
- # skills_exist
463
- skills_dir = File.join(agent_dir, "skills", "plastic")
464
- if File.directory?(skills_dir) && !Dir.empty?(skills_dir)
465
- checks << check(
473
+ # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
474
+ checks << flat_skills_check(agent_dir, "--claude")
475
+
476
+ checks
477
+ end
478
+
479
+ # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
480
+ # least one such skill is present.
481
+ def flat_skills_check(agent_dir, installer_flag)
482
+ skills_root = File.join(agent_dir, "skills")
483
+ found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
484
+
485
+ if !found.empty?
486
+ check(
466
487
  category: "agent_registration", name: "skills_exist", status: "pass",
467
- message: "Skills directory exists at #{tilde(skills_dir)}"
488
+ message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
468
489
  )
469
490
  else
470
- checks << check(
491
+ check(
471
492
  category: "agent_registration", name: "skills_exist", status: "fail",
472
- message: "Skills directory missing or empty at #{tilde(skills_dir)}",
473
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
493
+ message: "No plastic-* skills found in #{tilde(skills_root)}",
494
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
474
495
  )
475
496
  end
476
-
477
- checks
478
497
  end
479
498
 
480
499
  def check_generic_agent_registration(agent_key, agent_dir)
@@ -482,19 +501,7 @@ def check_generic_agent_registration(agent_key, agent_dir)
482
501
  config = AGENTS[agent_key]
483
502
 
484
503
  # For codex/hermes: just check skills exist (no settings.json hooks)
485
- skills_dir = File.join(agent_dir, "skills", "plastic")
486
- if File.directory?(skills_dir) && !Dir.empty?(skills_dir)
487
- checks << check(
488
- category: "agent_registration", name: "skills_exist", status: "pass",
489
- message: "Skills directory exists at #{tilde(skills_dir)}"
490
- )
491
- else
492
- checks << check(
493
- category: "agent_registration", name: "skills_exist", status: "fail",
494
- message: "Skills directory missing or empty at #{tilde(skills_dir)}",
495
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --#{agent_key}"
496
- )
497
- end
504
+ checks << flat_skills_check(agent_dir, "--#{agent_key}")
498
505
 
499
506
  checks
500
507
  end
@@ -695,7 +702,7 @@ def check_project_stores
695
702
  checks << check(
696
703
  category: "project_stores", name: "project_yml_exists", status: "warn",
697
704
  message: "project.yml missing for project '#{slug}'",
698
- fixable: true, fix_hint: "Create project.yml from template — see plastic:creating-project"
705
+ fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
699
706
  )
700
707
  end
701
708
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Usage: hook-auto-arm <message>
6
6
  # UserPromptSubmit arming (intent 27). Two jobs, both additive context — never blocks:
7
- # 1. Detect auto-trigger phrases -> steer the agent to invoke plastic:auto.
7
+ # 1. Detect auto-trigger phrases -> steer the agent to invoke plastic-auto.
8
8
  # 2. If auto mode is armed and the active intent hasn't reached How -> remind the agent
9
9
  # that project-code edits are gate-blocked until plan.md + checklist.md exist.
10
10
  # Outputs UserPromptSubmit hookSpecificOutput JSON, or exits silently.
@@ -16,11 +16,11 @@ message = (ARGV[0] || "").downcase
16
16
  parts = []
17
17
 
18
18
  # --- 1. Auto-trigger steering ---
19
- AUTO_TRIGGERS = ["take it from here", "deliver this", "/plastic:auto", "auto mode"].freeze
19
+ AUTO_TRIGGERS = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
20
20
  # bare "auto" only as a standalone word to avoid matching "automatic", "automation", etc.
21
21
  triggered = AUTO_TRIGGERS.any? { |p| message.include?(p) } || message.match?(/\bauto\b/)
22
22
  if triggered
23
- parts << "Auto-mode request detected. Invoke the plastic:auto skill to deliver the " \
23
+ parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
24
24
  "active intent autonomously — it arms the lifecycle gate so project code " \
25
25
  "cannot be edited before plan.md + checklist.md exist."
26
26
  end
@@ -116,7 +116,7 @@ if stale.any?
116
116
  parts << ""
117
117
  end
118
118
 
119
- parts << "Follow the plastic:continuing skill workflow.\n"
119
+ parts << "Follow the plastic-continuing skill workflow.\n"
120
120
  parts << "1. If active intents exist: read their state and resume\n"
121
121
  parts << "2. If no active intents: present future intents as options\n"
122
122
  parts << "3. If stale intents exist: surface them and ask user to decide"
@@ -102,9 +102,9 @@ if is_stage_file || is_action_file
102
102
  stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
103
103
  next_hints = {
104
104
  "why" => "write spec.md",
105
- "how" => "Why complete. Invoke plastic:auto to deliver autonomously, or write plan.md manually.",
106
- "exec" => "How complete. Invoke plastic:auto or plastic:executing-plan to execute, or work through the checklist manually.",
107
- "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic:auto or do it manually."
105
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
106
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
107
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
108
108
  }
109
109
 
110
110
  context_parts = ["PLASTIC"]
@@ -168,7 +168,7 @@ cache_file = "#{store_root}/.cache/update-check.json"
168
168
  if File.exist?(cache_file)
169
169
  cache = JSON.parse(File.read(cache_file)) rescue {}
170
170
  if cache["updateAvailable"]
171
- update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic:update"
171
+ update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
172
172
  end
173
173
  end
174
174
 
@@ -168,6 +168,7 @@ def distribute(mode)
168
168
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
169
169
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
170
170
  "scripts/doctor.rb" => "scripts/doctor.rb",
171
+ "scripts/dashboard.rb" => "scripts/dashboard.rb",
171
172
  }
172
173
 
173
174
  core_files.each do |src, dest|
@@ -258,6 +259,19 @@ end
258
259
 
259
260
  # --- Agent adapters ---
260
261
 
262
+ def manifest_path_for(key, config)
263
+ case key
264
+ when "claude" then File.join(config[:dir], "plastic", "manifest.json")
265
+ else File.join(config[:dir], "plastic-manifest.json")
266
+ end
267
+ end
268
+
269
+ def manifest_files(manifest_path)
270
+ return [] unless File.exist?(manifest_path)
271
+ data = JSON.parse(File.read(manifest_path)) rescue {}
272
+ (data["files"] || {}).keys
273
+ end
274
+
261
275
  def install_for_agent(key, force)
262
276
  config = agent_config(key)
263
277
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
@@ -266,20 +280,47 @@ def install_for_agent(key, force)
266
280
  return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
267
281
  end
268
282
 
269
- case key
270
- when "claude" then install_claude(config, force)
271
- when "codex" then install_codex(config, force)
272
- when "hermes" then install_hermes(config, force)
283
+ # Capture the prior manifest so we can prune files that no longer ship
284
+ # (renamed/removed skills) after a re-copy. This gives leftover-free updates.
285
+ old_files = manifest_files(manifest_path_for(key, config))
286
+
287
+ result = case key
288
+ when "claude" then install_claude(config, force)
289
+ when "codex" then install_codex(config, force)
290
+ when "hermes" then install_hermes(config, force)
291
+ end
292
+
293
+ new_files = manifest_files(manifest_path_for(key, config))
294
+ pruned = prune_removed_files(old_files - new_files)
295
+ result[:pruned] = pruned if pruned.positive?
296
+ result
297
+ end
298
+
299
+ # Delete tracked files present in the old manifest but absent from the new one,
300
+ # then remove any now-empty skill directories they lived in.
301
+ def prune_removed_files(stale_files)
302
+ removed = 0
303
+ dirs = []
304
+ stale_files.each do |f|
305
+ if File.exist?(f)
306
+ File.delete(f)
307
+ removed += 1
308
+ end
309
+ dirs << File.dirname(f)
273
310
  end
311
+ dirs.uniq.sort_by { |d| -d.length }.each do |d|
312
+ FileUtils.rmdir(d) if File.directory?(d) && Dir.empty?(d)
313
+ end
314
+ removed
274
315
  end
275
316
 
276
317
  def install_claude(config, force)
277
318
  hooks_dir = File.join(config[:dir], "hooks")
278
- skills_dir = File.join(config[:dir], "skills", "plastic")
319
+ skills_root = File.join(config[:dir], "skills")
279
320
  plastic_dir = File.join(config[:dir], "plastic")
280
321
 
281
322
  FileUtils.mkdir_p(hooks_dir)
282
- FileUtils.mkdir_p(skills_dir)
323
+ FileUtils.mkdir_p(skills_root)
283
324
  FileUtils.mkdir_p(plastic_dir)
284
325
 
285
326
  installed = []
@@ -299,19 +340,19 @@ def install_claude(config, force)
299
340
  installed << dest
300
341
  end
301
342
 
302
- # Copy skills recursively
343
+ # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/)
303
344
  skills_source = File.join(PACKAGE_ROOT, "skills")
304
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
345
+ installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
305
346
 
306
347
  # Write VERSION
307
348
  version_file = File.join(plastic_dir, "VERSION")
308
349
  File.write(version_file, "#{VERSION}\n")
309
350
  installed << version_file
310
351
 
311
- # Sync marketplace plugin so Claude Code discovers skills
312
- sync_marketplace_plugin(config[:dir])
352
+ # Remove any prior plugin/marketplace install so the layouts don't coexist
353
+ migrate_legacy_plugin(config[:dir])
313
354
 
314
- # Merge hooks + enabledPlugins into settings.json
355
+ # Merge hooks + statusline into settings.json (no plugin registration)
315
356
  settings_path = File.join(config[:dir], "settings.json")
316
357
  merge_claude_hooks(settings_path)
317
358
 
@@ -323,99 +364,106 @@ def install_claude(config, force)
323
364
  end
324
365
 
325
366
  def install_codex(config, force)
326
- skills_dir = File.join(config[:dir], "skills", "plastic")
327
- FileUtils.mkdir_p(skills_dir)
328
-
329
367
  installed = []
330
368
  skills_source = File.join(PACKAGE_ROOT, "skills")
331
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
332
-
333
- manifest_path = File.join(config[:dir], "plastic-manifest.json")
334
- write_manifest(installed, manifest_path)
369
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
335
370
 
371
+ write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
336
372
  { agent: config[:name], success: true, files: installed.size }
337
373
  end
338
374
 
339
375
  def install_hermes(config, force)
340
- skills_dir = File.join(config[:dir], "skills", "plastic")
341
- FileUtils.mkdir_p(skills_dir)
342
-
343
376
  installed = []
344
377
  skills_source = File.join(PACKAGE_ROOT, "skills")
345
- installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
346
-
347
- manifest_path = File.join(config[:dir], "plastic-manifest.json")
348
- write_manifest(installed, manifest_path)
378
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
349
379
 
380
+ write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
350
381
  { agent: config[:name], success: true, files: installed.size }
351
382
  end
352
383
 
353
- # --- Marketplace plugin sync ---
384
+ # Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
385
+ # directory name — the only personal-skill namespacing Claude Code supports).
386
+ # The non-skill `_active-intent-gate.md` is relocated to ~/.plastic/ instead.
387
+ def install_skills_flat(skills_source, skills_root)
388
+ installed = []
389
+ FileUtils.mkdir_p(skills_root)
390
+
391
+ Dir.children(skills_source).reject { |e| e.start_with?(".") }.each do |entry|
392
+ src = File.join(skills_source, entry)
393
+ if File.directory?(src)
394
+ installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
395
+ elsif entry == "_active-intent-gate.md"
396
+ FileUtils.mkdir_p(PLASTIC_HOME)
397
+ dest = File.join(PLASTIC_HOME, entry)
398
+ FileUtils.cp(src, dest)
399
+ installed << dest
400
+ end
401
+ end
402
+
403
+ installed
404
+ end
405
+
406
+ # --- Legacy plugin migration ---
407
+
408
+ # Earlier versions registered Plastic as a local marketplace plugin
409
+ # (plastic@plastic) for skill discovery. The current model uses flat,
410
+ # hyphen-namespaced personal skills, so any prior plugin layout is removed here
411
+ # to stop the two coexisting. Returns a list of what was migrated.
412
+ def migrate_legacy_plugin(claude_dir)
413
+ removed = []
414
+
415
+ legacy_dirs = [
416
+ File.join(claude_dir, "plugins", "marketplaces", "plastic"),
417
+ File.join(claude_dir, "plugins", "cache", "plastic"),
418
+ File.join(claude_dir, "skills", "plastic"), # old nested skills layout
419
+ ]
420
+ legacy_dirs.each do |d|
421
+ if File.directory?(d)
422
+ FileUtils.rm_rf(d)
423
+ removed << d
424
+ end
425
+ end
354
426
 
355
- def sync_marketplace_plugin(claude_dir)
356
- marketplace_dir = File.join(claude_dir, "plugins", "marketplaces", "plastic")
357
- plugin_meta_dir = File.join(marketplace_dir, ".claude-plugin")
427
+ # settings.json: drop enabledPlugins + extraKnownMarketplaces entries
428
+ settings_path = File.join(claude_dir, "settings.json")
429
+ settings = read_json_safe(settings_path)
430
+ if settings
431
+ changed = false
432
+ if settings.dig("enabledPlugins", "plastic@plastic")
433
+ settings["enabledPlugins"].delete("plastic@plastic")
434
+ settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
435
+ changed = true
436
+ end
437
+ if settings.dig("extraKnownMarketplaces", "plastic")
438
+ settings["extraKnownMarketplaces"].delete("plastic")
439
+ settings.delete("extraKnownMarketplaces") if settings["extraKnownMarketplaces"].empty?
440
+ changed = true
441
+ end
442
+ if changed
443
+ write_json_atomic(settings_path, settings)
444
+ removed << "settings.json: plastic@plastic plugin registration"
445
+ end
446
+ end
358
447
 
359
- FileUtils.mkdir_p(plugin_meta_dir)
448
+ # plugins/known_marketplaces.json: drop the plastic entry
449
+ known_path = File.join(claude_dir, "plugins", "known_marketplaces.json")
450
+ known = read_json_safe(known_path)
451
+ if known.is_a?(Hash) && known.key?("plastic")
452
+ known.delete("plastic")
453
+ write_json_atomic(known_path, known)
454
+ removed << "known_marketplaces.json: plastic"
455
+ end
360
456
 
361
- plugin_json = {
362
- "name" => "plastic",
363
- "description" => "Intent-driven state management for AI coding sessions. Neuroplasticity for your codebase.",
364
- "version" => VERSION,
365
- "author" => { "name" => "Zlatko Alomerovic", "email" => "zlatko.alomerovic@gmail.com" },
366
- "homepage" => "https://github.com/zalom/plastic",
367
- "repository" => "https://github.com/zalom/plastic",
368
- "license" => "MIT",
369
- "keywords" => %w[intent-driven state-management zettelkasten ai-agent]
370
- }
371
- write_json_atomic(File.join(plugin_meta_dir, "plugin.json"), plugin_json)
372
-
373
- marketplace_json = {
374
- "name" => "plastic",
375
- "description" => "Marketplace for Plastic — intent-driven state management",
376
- "owner" => { "name" => "Zlatko Alomerovic", "email" => "zlatko.alomerovic@gmail.com" },
377
- "plugins" => [{
378
- "name" => "plastic",
379
- "description" => "Intent-driven state management for AI coding sessions",
380
- "version" => VERSION,
381
- "source" => "./",
382
- "author" => { "name" => "Zlatko Alomerovic", "email" => "zlatko.alomerovic@gmail.com" }
383
- }]
384
- }
385
- write_json_atomic(File.join(plugin_meta_dir, "marketplace.json"), marketplace_json)
457
+ unless removed.empty?
458
+ puts " \u{1f9f9} Migrated legacy plugin install:"
459
+ removed.each { |r| puts " - #{tilde(r)}" }
460
+ end
386
461
 
387
- # Sync skills
388
- skills_source = File.join(PACKAGE_ROOT, "skills")
389
- skills_dest = File.join(marketplace_dir, "skills")
390
- if File.directory?(skills_source)
391
- FileUtils.rm_rf(skills_dest)
392
- copy_dir_recursive(skills_source, skills_dest)
393
- end
394
-
395
- # Sync agents
396
- agents_source = File.join(PACKAGE_ROOT, "agents")
397
- agents_dest = File.join(marketplace_dir, "agents")
398
- if File.directory?(agents_source)
399
- FileUtils.rm_rf(agents_dest)
400
- copy_dir_recursive(agents_source, agents_dest)
401
- end
402
-
403
- # Write .claude/settings.json (permissions)
404
- claude_settings_dir = File.join(marketplace_dir, ".claude")
405
- FileUtils.mkdir_p(claude_settings_dir)
406
- write_json_atomic(File.join(claude_settings_dir, "settings.json"), {
407
- "permissions" => {
408
- "allow" => [
409
- "Bash(ruby *)",
410
- "Bash(ls *)",
411
- "Bash(find *)",
412
- "Bash(git *)",
413
- "Bash(mkdir *)",
414
- "Bash(chmod *)",
415
- "mcp__serena__*"
416
- ]
417
- }
418
- })
462
+ removed
463
+ end
464
+
465
+ def tilde(path)
466
+ path.sub(Dir.home, "~")
419
467
  end
420
468
 
421
469
  # --- settings.json merge (read-modify-write, never clobber) ---
@@ -486,13 +534,8 @@ def merge_claude_hooks(settings_path)
486
534
 
487
535
  settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
488
536
 
489
- # Register Plastic as an enabled plugin for skill discovery
490
- plugins = settings["enabledPlugins"] ||= {}
491
- plugins["plastic@plastic"] = true
492
-
493
- # Register the marketplace source
494
- marketplaces = settings["extraKnownMarketplaces"] ||= {}
495
- marketplaces["plastic"] ||= { "source" => { "source" => "github", "repo" => "zalom/plastic" } }
537
+ # No plugin/marketplace registration: skills are flat personal skills
538
+ # (plastic-<name>/) discovered directly from ~/.claude/skills.
496
539
 
497
540
  write_json_atomic(settings_path, settings)
498
541
  end
@@ -526,14 +569,25 @@ def handle_uninstall(agents)
526
569
  next unless config
527
570
 
528
571
  result = uninstall_agent(key, config)
529
- if result[:success]
530
- puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
531
- else
572
+ unless result[:success]
532
573
  puts " \u{26a0}\u{fe0f} #{config[:name]}: #{result[:reason]}"
574
+ next
533
575
  end
576
+
577
+ puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
578
+ result[:removed].each { |r| puts " removed: #{tilde(r)}" }
534
579
  end
535
580
 
536
- puts "\n Note: ~/.plastic/ (your intent store) is preserved.\n\n"
581
+ # What was deliberately left behind
582
+ puts "\n Left in place (not removed):"
583
+ puts " - #{tilde(PLASTIC_HOME)} (your intent store, history, and projects)"
584
+ puts " - any non-Plastic entries in settings.json"
585
+
586
+ puts "\n Verify removal:"
587
+ puts " ls ~/.claude/skills | grep '^plastic-' # → no output"
588
+ puts " ls ~/.claude/hooks | grep '^plastic-' # → no output"
589
+ puts " grep -c plastic ~/.claude/settings.json # → only hook refs gone"
590
+ puts "\n To also delete your intent store: rm -rf #{tilde(PLASTIC_HOME)}\n\n"
537
591
  end
538
592
 
539
593
  def uninstall_agent(key, config)
@@ -541,45 +595,47 @@ def uninstall_agent(key, config)
541
595
  return { success: false, reason: "#{config[:dir]} not found" }
542
596
  end
543
597
 
544
- manifest_path = case key
545
- when "claude" then File.join(config[:dir], "plastic", "manifest.json")
546
- else File.join(config[:dir], "plastic-manifest.json")
547
- end
548
-
549
- files_removed = 0
598
+ removed = []
599
+ manifest_path = manifest_path_for(key, config)
550
600
 
551
601
  if File.exist?(manifest_path)
552
602
  manifest = JSON.parse(File.read(manifest_path)) rescue {}
553
603
  (manifest["files"] || {}).each_key do |f|
554
604
  if File.exist?(f)
555
605
  File.delete(f)
556
- files_removed += 1
606
+ removed << f
557
607
  end
558
608
  end
559
609
  File.delete(manifest_path)
560
- files_removed += 1
610
+ removed << manifest_path
561
611
  end
562
612
 
563
- # Clean known directories
564
- dirs_to_clean = case key
565
- when "claude"
566
- [
567
- File.join(config[:dir], "plastic"),
568
- File.join(config[:dir], "skills", "plastic"),
569
- File.join(config[:dir], "plugins", "marketplaces", "plastic"),
570
- ]
571
- else [File.join(config[:dir], "skills", "plastic")]
572
- end
613
+ # Remove flat plastic-<name>/ skill dirs (now-empty after manifest deletion,
614
+ # plus any the manifest missed) and the plastic state dir.
615
+ skills_root = File.join(config[:dir], "skills")
616
+ if File.directory?(skills_root)
617
+ Dir.children(skills_root).select { |e| e.start_with?("plastic-") }.each do |d|
618
+ full = File.join(skills_root, d)
619
+ FileUtils.rm_rf(full)
620
+ removed << full
621
+ end
622
+ end
573
623
 
574
- dirs_to_clean.each { |d| FileUtils.rm_rf(d) if File.directory?(d) }
624
+ [File.join(config[:dir], "plastic")].each do |d|
625
+ if File.directory?(d)
626
+ FileUtils.rm_rf(d)
627
+ removed << d
628
+ end
629
+ end
575
630
 
576
- # Clean hooks from settings.json (Claude Code only)
631
+ # Claude Code: clean hooks/statusline and any legacy plugin registration
577
632
  if key == "claude"
578
633
  settings_path = File.join(config[:dir], "settings.json")
579
634
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
635
+ removed.concat(migrate_legacy_plugin(config[:dir]))
580
636
  end
581
637
 
582
- { success: true, files: files_removed }
638
+ { success: true, files: removed.size, removed: removed }
583
639
  end
584
640
 
585
641
  def remove_claude_hooks(settings_path)
@@ -211,7 +211,7 @@ module Bridge
211
211
 
212
212
  id = intent_info["id"]
213
213
  "intent #{id} has not reached How — write plan.md + checklist.md before " \
214
- "editing project code. Run plastic:auto or plastic:writing-plans first. " \
214
+ "editing project code. Run plastic-auto or plastic-writing-plans first. " \
215
215
  "(blocked edit: #{file_abs})"
216
216
  end
217
217
 
@@ -17,7 +17,7 @@ GLOBAL_ROOT="$HOME/.plastic"
17
17
  LOCAL_PLASTIC="$PROJECT_PATH/.plastic"
18
18
 
19
19
  if [ ! -d "$GLOBAL_ROOT" ]; then
20
- echo "Error: Global Plastic not installed. Run /plastic:install first." >&2
20
+ echo "Error: Global Plastic not installed. Run /plastic-install first." >&2
21
21
  exit 1
22
22
  fi
23
23
 
@@ -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-creating-intent"
17
17
 
18
18
  3. **Resolve paths:**
19
19
  - Intent directory: `{store}/store/{id}--{slug}/`