@zalom/plastic 1.0.0-alpha.1

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/PLASTIC.md +534 -0
  3. package/README.md +88 -0
  4. package/agents/future-intent-researcher.md +38 -0
  5. package/agents/intent-curator.md +40 -0
  6. package/bin/install.js +29 -0
  7. package/deprecations.yml +23 -0
  8. package/hooks/check-update +42 -0
  9. package/hooks/continue +31 -0
  10. package/hooks/future-intent-check +25 -0
  11. package/hooks/gate-check +10 -0
  12. package/hooks/hooks.json +78 -0
  13. package/hooks/run-hook +7 -0
  14. package/hooks/savepoint +6 -0
  15. package/hooks/session-start +9 -0
  16. package/hooks/statusline +16 -0
  17. package/package.json +43 -0
  18. package/scripts/folgezettel-id +40 -0
  19. package/scripts/hash-intent +29 -0
  20. package/scripts/hook-continue +130 -0
  21. package/scripts/hook-future-intent-check +90 -0
  22. package/scripts/hook-gate-check +136 -0
  23. package/scripts/hook-session-start +224 -0
  24. package/scripts/install.rb +474 -0
  25. package/scripts/lib/bridge.rb +139 -0
  26. package/scripts/migrate-folgezettel +535 -0
  27. package/scripts/migrate-to-global +96 -0
  28. package/scripts/read-config +129 -0
  29. package/skills/auto/SKILL.md +127 -0
  30. package/skills/brainstorming-grill-me/SKILL.md +105 -0
  31. package/skills/continuing/SKILL.md +104 -0
  32. package/skills/creating-intent/SKILL.md +122 -0
  33. package/skills/creating-project/SKILL.md +166 -0
  34. package/skills/executing-plan/SKILL.md +120 -0
  35. package/skills/executing-plan/code-quality-reviewer-prompt.md +32 -0
  36. package/skills/executing-plan/implementer-prompt.md +42 -0
  37. package/skills/executing-plan/spec-reviewer-prompt.md +27 -0
  38. package/skills/install/SKILL.md +134 -0
  39. package/skills/intent-curator/SKILL.md +41 -0
  40. package/skills/linking-intents/SKILL.md +72 -0
  41. package/skills/managing-index/SKILL.md +66 -0
  42. package/skills/managing-index/references/zettelkasten-linking.md +27 -0
  43. package/skills/releasing/SKILL.md +124 -0
  44. package/skills/savepoint/SKILL.md +57 -0
  45. package/skills/uninstall/SKILL.md +48 -0
  46. package/skills/update/SKILL.md +69 -0
  47. package/templates/agents.md +46 -0
  48. package/templates/checklist.md +11 -0
  49. package/templates/config.yml +13 -0
  50. package/templates/index.md +13 -0
  51. package/templates/intent.md +24 -0
  52. package/templates/plan.md +11 -0
  53. package/templates/projects.yml +3 -0
  54. package/templates/savepoint.md +13 -0
@@ -0,0 +1,535 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Migrate a Plastic store from NNN-HASH naming to Folgezettel naming.
6
+ #
7
+ # Usage:
8
+ # migrate-folgezettel <store_root> [--dry-run]
9
+ #
10
+ # Phases:
11
+ # 1. Read all intents, parse frontmatter
12
+ # 2. Build knowledge graph from sources/chain
13
+ # 3. Assign Folgezettel IDs via DFS traversal
14
+ # 4. Update markdown file contents (frontmatter + wikilinks)
15
+ # 5. Rename intent.md -> {ID}.md inside each directory
16
+ # 6. Rename directories from NNN--slug-HASH -> ID--slug
17
+ # 7. Rewrite INDEX.md paths and display text
18
+ # 8. Update projects.yml parent references
19
+
20
+ require "yaml"
21
+ require "fileutils"
22
+
23
+ # --- Intent data structure ---
24
+
25
+ Intent = Struct.new(
26
+ :old_id, # e.g. "001"
27
+ :old_id_unpadded, # e.g. "1"
28
+ :dir_name, # e.g. "001--research-reddit-saved-posts-3k8gyi"
29
+ :hash_suffix, # e.g. "3k8gyi"
30
+ :slug, # e.g. "research-reddit-saved-posts"
31
+ :title, # from frontmatter intent field
32
+ :sources, # array of old_id strings (padded to 3)
33
+ :chain, # array of old_id strings (padded to 3)
34
+ :created, # date string
35
+ :folgezettel_id, # assigned Folgezettel ID
36
+ :raw_frontmatter, # original frontmatter text
37
+ keyword_init: true
38
+ )
39
+
40
+ # --- Helpers ---
41
+
42
+ def pad3(id)
43
+ id.to_s.rjust(3, "0")
44
+ end
45
+
46
+ def unpad(id)
47
+ id.to_s.sub(/\A0+/, "").then { |s| s.empty? ? "0" : s }
48
+ end
49
+
50
+ def parse_id_list(value)
51
+ return [] if value.nil?
52
+ return [] if value.is_a?(Array) && value.empty?
53
+
54
+ if value.is_a?(Array)
55
+ value.map { |v| pad3(v.to_s.delete("'\"").strip) }
56
+ elsif value.is_a?(String)
57
+ value.strip.delete("'\"").split(",").map { |v| pad3(v.strip) }
58
+ else
59
+ []
60
+ end
61
+ end
62
+
63
+ def parse_frontmatter(text)
64
+ return [{}, text] unless text.start_with?("---")
65
+
66
+ parts = text.split(/^---\s*$/, 3)
67
+ return [{}, text] if parts.length < 3
68
+
69
+ fm_text = parts[1]
70
+ body = "---\n#{fm_text}---#{parts[2]}"
71
+
72
+ begin
73
+ data = YAML.safe_load(fm_text, permitted_classes: [Date]) || {}
74
+ rescue => e
75
+ $stderr.puts " YAML parse warning: #{e.message}"
76
+ data = {}
77
+ end
78
+
79
+ [data, fm_text]
80
+ end
81
+
82
+ def parse_dir_name(dir_name)
83
+ # Format: NNN--slug-HASH
84
+ # e.g. 001--research-reddit-saved-posts-3k8gyi
85
+ match = dir_name.match(/\A(\d+)--(.+)\z/)
86
+ return nil unless match
87
+
88
+ nnn = match[1]
89
+ rest = match[2]
90
+
91
+ # Split slug from hash: last 6 alphanumeric chars after final hyphen
92
+ slug_hash_match = rest.match(/\A(.+)-([a-z0-9]{6})\z/)
93
+ return nil unless slug_hash_match
94
+
95
+ slug = slug_hash_match[1]
96
+ hash_suffix = slug_hash_match[2]
97
+
98
+ { nnn: nnn, slug: slug, hash: hash_suffix }
99
+ end
100
+
101
+ # --- Phase 1: Read all intents ---
102
+
103
+ def read_intents(store_path)
104
+ intents = {}
105
+
106
+ Dir.glob("#{store_path}/*--*").sort.each do |dir|
107
+ dir_name = File.basename(dir)
108
+ intent_file = File.join(dir, "intent.md")
109
+ next unless File.exist?(intent_file)
110
+
111
+ parsed = parse_dir_name(dir_name)
112
+ next unless parsed
113
+
114
+ text = File.read(intent_file)
115
+ data, fm_text = parse_frontmatter(text)
116
+
117
+ old_id = pad3(data["id"].to_s.delete("'\"").strip)
118
+
119
+ intent = Intent.new(
120
+ old_id: old_id,
121
+ old_id_unpadded: unpad(old_id),
122
+ dir_name: dir_name,
123
+ hash_suffix: parsed[:hash],
124
+ slug: parsed[:slug],
125
+ title: (data["intent"] || "").to_s.split(" — ").first.split("—").first.strip,
126
+ sources: parse_id_list(data["sources"]),
127
+ chain: parse_id_list(data["chain"]),
128
+ created: data["created"].to_s,
129
+ folgezettel_id: nil,
130
+ raw_frontmatter: fm_text
131
+ )
132
+
133
+ intents[old_id] = intent
134
+ end
135
+
136
+ intents
137
+ end
138
+
139
+ # --- Phase 2 & 3: Build graph and assign Folgezettel IDs ---
140
+
141
+ def assign_folgezettel_ids(intents)
142
+ # Build parent->children map using sources (reverse: source is parent)
143
+ children_of = Hash.new { |h, k| h[k] = [] }
144
+
145
+ intents.each_value do |intent|
146
+ if intent.sources.empty? || intent.sources.none? { |s| intents.key?(s) }
147
+ # Root intent — no parent in store
148
+ else
149
+ # Primary parent is FIRST source that exists in store
150
+ primary_parent = intent.sources.find { |s| intents.key?(s) }
151
+ children_of[primary_parent] << intent.old_id if primary_parent
152
+ end
153
+ end
154
+
155
+ # Sort children by creation date
156
+ children_of.each do |parent_id, child_ids|
157
+ children_of[parent_id] = child_ids.sort_by { |cid| intents[cid].created }
158
+ end
159
+
160
+ # Find roots: intents with no sources or no source in store
161
+ roots = intents.values.select do |intent|
162
+ intent.sources.empty? || intent.sources.none? { |s| intents.key?(s) }
163
+ end.sort_by(&:created)
164
+
165
+ # DFS assignment
166
+ assigned = {}
167
+ root_counter = 0
168
+
169
+ assign_children = lambda do |parent_fz_id, parent_old_id|
170
+ kids = children_of[parent_old_id] || []
171
+ return if kids.empty?
172
+
173
+ # Determine if children get letters or digits
174
+ use_letter = parent_fz_id[-1].match?(/\d/)
175
+
176
+ kids.each_with_index do |child_id, idx|
177
+ next if assigned[child_id] # skip if already assigned
178
+
179
+ if use_letter
180
+ suffix = ("a".ord + idx).chr
181
+ else
182
+ suffix = (idx + 1).to_s
183
+ end
184
+
185
+ fz_id = "#{parent_fz_id}#{suffix}"
186
+ intents[child_id].folgezettel_id = fz_id
187
+ assigned[child_id] = true
188
+
189
+ # Recurse
190
+ assign_children.call(fz_id, child_id)
191
+ end
192
+ end
193
+
194
+ roots.each do |intent|
195
+ root_counter += 1
196
+ fz_id = root_counter.to_s
197
+ intent.folgezettel_id = fz_id
198
+ assigned[intent.old_id] = true
199
+
200
+ assign_children.call(fz_id, intent.old_id)
201
+ end
202
+
203
+ # Check for unassigned (orphans that somehow weren't caught as roots)
204
+ intents.each_value do |intent|
205
+ unless assigned[intent.old_id]
206
+ root_counter += 1
207
+ intent.folgezettel_id = root_counter.to_s
208
+ assigned[intent.old_id] = true
209
+ $stderr.puts " Warning: orphan intent #{intent.old_id} assigned root #{intent.folgezettel_id}"
210
+ assign_children.call(intent.folgezettel_id, intent.old_id)
211
+ end
212
+ end
213
+ end
214
+
215
+ # --- Build mapping tables ---
216
+
217
+ def build_mappings(intents)
218
+ # old_id (padded) -> folgezettel_id
219
+ id_map = {}
220
+ # old_dir_name -> new_dir_name
221
+ dir_map = {}
222
+ # old_id_unpadded-hash -> folgezettel_id (for wikilinks)
223
+ wikilink_map = {}
224
+
225
+ intents.each_value do |intent|
226
+ id_map[intent.old_id] = intent.folgezettel_id
227
+ id_map[intent.old_id_unpadded] = intent.folgezettel_id
228
+
229
+ new_dir = "#{intent.folgezettel_id}--#{intent.slug}"
230
+ dir_map[intent.dir_name] = new_dir
231
+
232
+ # Wikilinks may use padded or unpadded: "006-2yv12k" or "6-2yv12k"
233
+ wikilink_map["#{intent.old_id_unpadded}-#{intent.hash_suffix}"] = intent.folgezettel_id
234
+ wikilink_map["#{intent.old_id}-#{intent.hash_suffix}"] = intent.folgezettel_id
235
+ end
236
+
237
+ { id_map: id_map, dir_map: dir_map, wikilink_map: wikilink_map }
238
+ end
239
+
240
+ # --- Phase 4: Update file contents ---
241
+
242
+ def update_file_contents(store_path, intents, mappings, dry_run:)
243
+ wikilink_map = mappings[:wikilink_map]
244
+ id_map = mappings[:id_map]
245
+
246
+ intents.each_value do |intent|
247
+ dir = File.join(store_path, intent.dir_name)
248
+
249
+ Dir.glob("#{dir}/**/*.md").each do |md_file|
250
+ text = File.read(md_file)
251
+ updated = text.dup
252
+
253
+ # Replace wikilinks: [[NNN-HASH]] -> [[folgezettel_id]]
254
+ # Also handles [[NNN-HASH|display text]] and [[global:NNN-HASH]]
255
+ updated.gsub!(/\[\[(global:)?(\d+-[a-z0-9]{5,6})(\|[^\]]+)?\]\]/) do |match|
256
+ prefix = $1 || ""
257
+ old_ref = $2
258
+ display = $3 || ""
259
+
260
+ new_id = wikilink_map[old_ref]
261
+ if new_id
262
+ "[[#{prefix}#{new_id}#{display}]]"
263
+ else
264
+ match # leave unchanged if not found
265
+ end
266
+ end
267
+
268
+ # Replace frontmatter id field
269
+ updated.gsub!(/^(id:\s*)['"]?\d{1,3}['"]?\s*$/) do |match|
270
+ "#{$1}'#{intent.folgezettel_id}'"
271
+ end
272
+
273
+ # Replace source/chain references in frontmatter
274
+ # Handle both inline and block styles
275
+ updated.gsub!(/^(\s*-\s*)['"]?(\d{1,3})['"]?\s*$/) do |match|
276
+ ref_id = pad3($2)
277
+ new_id = id_map[ref_id]
278
+ if new_id
279
+ "#{$1}'#{new_id}'"
280
+ else
281
+ match
282
+ end
283
+ end
284
+
285
+ if updated != text
286
+ if dry_run
287
+ puts " Would update: #{md_file}"
288
+ else
289
+ File.write(md_file, updated)
290
+ puts " Updated: #{md_file}"
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ # --- Phase 5: Rename intent.md -> {ID}.md ---
298
+
299
+ def rename_intent_files(store_path, intents, dry_run:)
300
+ intents.each_value do |intent|
301
+ dir = File.join(store_path, intent.dir_name)
302
+ old_file = File.join(dir, "intent.md")
303
+ new_file = File.join(dir, "#{intent.folgezettel_id}.md")
304
+
305
+ next unless File.exist?(old_file)
306
+
307
+ if dry_run
308
+ puts " Would rename: intent.md -> #{intent.folgezettel_id}.md (in #{intent.dir_name})"
309
+ else
310
+ File.rename(old_file, new_file)
311
+ puts " Renamed: intent.md -> #{intent.folgezettel_id}.md (in #{intent.dir_name})"
312
+ end
313
+ end
314
+ end
315
+
316
+ # --- Phase 6: Rename directories ---
317
+
318
+ def rename_directories(store_path, intents, mappings, dry_run:)
319
+ dir_map = mappings[:dir_map]
320
+
321
+ # Sort by path length descending to avoid conflicts
322
+ sorted = intents.values.sort_by { |i| -i.dir_name.length }
323
+
324
+ sorted.each do |intent|
325
+ old_dir = File.join(store_path, intent.dir_name)
326
+ new_dir_name = dir_map[intent.dir_name]
327
+ new_dir = File.join(store_path, new_dir_name)
328
+
329
+ next unless Dir.exist?(old_dir)
330
+
331
+ if dry_run
332
+ puts " Would rename dir: #{intent.dir_name} -> #{new_dir_name}"
333
+ else
334
+ File.rename(old_dir, new_dir)
335
+ puts " Renamed dir: #{intent.dir_name} -> #{new_dir_name}"
336
+ end
337
+ end
338
+ end
339
+
340
+ # --- Phase 7: Rewrite INDEX.md ---
341
+
342
+ def rewrite_index(store_root, intents, mappings, dry_run:)
343
+ index_path = File.join(store_root, "INDEX.md")
344
+ return unless File.exist?(index_path)
345
+
346
+ text = File.read(index_path)
347
+ updated = text.dup
348
+
349
+ id_map = mappings[:id_map]
350
+ dir_map = mappings[:dir_map]
351
+
352
+ # Replace store paths: store/OLD_DIR/intent.md -> store/NEW_DIR/ID.md
353
+ dir_map.each do |old_dir, new_dir|
354
+ intent = intents.values.find { |i| i.dir_name == old_dir }
355
+ next unless intent
356
+
357
+ updated.gsub!("store/#{old_dir}/intent.md", "store/#{new_dir}/#{intent.folgezettel_id}.md")
358
+ end
359
+
360
+ # Replace display text in links: [NNN — -> [folgezettel_id —
361
+ intents.each_value do |intent|
362
+ # Match [001 — or [1 — patterns
363
+ updated.gsub!(/\[#{Regexp.escape(intent.old_id_unpadded.rjust(3, "0"))} —/, "[#{intent.folgezettel_id} —")
364
+ # Also handle unpadded if different
365
+ if intent.old_id_unpadded != intent.old_id
366
+ updated.gsub!(/\[#{Regexp.escape(intent.old_id_unpadded)} —/, "[#{intent.folgezettel_id} —")
367
+ end
368
+ end
369
+
370
+ # Replace "from: NNN+NNN" annotations
371
+ updated.gsub!(/from:\s*(\d{1,3})\+(\d{1,3})/) do
372
+ id1 = id_map[pad3($1)] || id_map[$1] || $1
373
+ id2 = id_map[pad3($2)] || id_map[$2] || $2
374
+ "from: #{id1}+#{id2}"
375
+ end
376
+
377
+ # Replace "from: NNN," (single source)
378
+ updated.gsub!(/from:\s*(\d{1,3})(?=[,\s])/) do
379
+ new_id = id_map[pad3($1)] || id_map[$1] || $1
380
+ "from: #{new_id}"
381
+ end
382
+
383
+ # Replace "after: NNN" annotations
384
+ updated.gsub!(/after:\s*(\d{1,3})/) do
385
+ new_id = id_map[pad3($1)] || id_map[$1] || $1
386
+ "after: #{new_id}"
387
+ end
388
+
389
+ # Replace "superseded by NNN"
390
+ updated.gsub!(/superseded by (\d{1,3})/) do
391
+ new_id = id_map[pad3($1)] || id_map[$1] || $1
392
+ "superseded by #{new_id}"
393
+ end
394
+
395
+ # Replace "→ NNN)" pattern (like "abandoned → 032")
396
+ updated.gsub!(/→\s*(\d{1,3})\)/) do
397
+ new_id = id_map[pad3($1)] || id_map[$1] || $1
398
+ "→ #{new_id})"
399
+ end
400
+
401
+ if updated != text
402
+ if dry_run
403
+ puts " Would update INDEX.md"
404
+ # Show the diff
405
+ text.lines.zip(updated.lines).each_with_index do |(old_line, new_line), idx|
406
+ if old_line != new_line
407
+ puts " L#{idx + 1}: #{old_line&.chomp}"
408
+ puts " => #{new_line&.chomp}"
409
+ end
410
+ end
411
+ else
412
+ File.write(index_path, updated)
413
+ puts " Updated INDEX.md"
414
+ end
415
+ end
416
+ end
417
+
418
+ # --- Phase 8: Update projects.yml ---
419
+
420
+ def update_projects_yml(store_root, mappings, dry_run:)
421
+ projects_path = File.join(store_root, "projects.yml")
422
+ return unless File.exist?(projects_path)
423
+
424
+ text = File.read(projects_path)
425
+ updated = text.dup
426
+ id_map = mappings[:id_map]
427
+
428
+ # Replace parent: 'NNN' references
429
+ updated.gsub!(/^(\s*parent:\s*)['"](\d{1,3})['"]\s*$/) do
430
+ prefix = $1
431
+ old_id = $2
432
+ new_id = id_map[pad3(old_id)] || id_map[old_id] || old_id
433
+ "#{prefix}'#{new_id}'"
434
+ end
435
+
436
+ if updated != text
437
+ if dry_run
438
+ puts " Would update projects.yml"
439
+ puts " #{text.lines.find { |l| l.include?("parent:") && l.include?("'") }&.chomp}"
440
+ puts " => #{updated.lines.find { |l| l.include?("parent:") && l.include?("'") }&.chomp}"
441
+ else
442
+ File.write(projects_path, updated)
443
+ puts " Updated projects.yml"
444
+ end
445
+ end
446
+ end
447
+
448
+ # --- Main ---
449
+
450
+ def main
451
+ store_root = ARGV[0]
452
+ dry_run = ARGV.include?("--dry-run")
453
+
454
+ unless store_root && Dir.exist?(store_root)
455
+ $stderr.puts "Usage: migrate-folgezettel <store_root> [--dry-run]"
456
+ $stderr.puts " store_root: path to Plastic root (e.g., ~/.plastic)"
457
+ exit 1
458
+ end
459
+
460
+ store_path = File.join(store_root, "store")
461
+
462
+ unless Dir.exist?(store_path)
463
+ $stderr.puts "Error: #{store_path} does not exist"
464
+ exit 1
465
+ end
466
+
467
+ puts "Plastic Folgezettel Migration"
468
+ puts "Store: #{store_root}"
469
+ puts "Mode: #{dry_run ? 'DRY RUN' : 'LIVE'}"
470
+ puts
471
+
472
+ # Phase 1: Read intents
473
+ puts "Phase 1: Reading intents..."
474
+ intents = read_intents(store_path)
475
+ puts " Found #{intents.size} intents"
476
+ puts
477
+
478
+ # Phase 2 & 3: Assign Folgezettel IDs
479
+ puts "Phase 2-3: Building graph and assigning Folgezettel IDs..."
480
+ assign_folgezettel_ids(intents)
481
+
482
+ # Display mapping
483
+ puts
484
+ puts "Mapping (#{intents.size} intents):"
485
+ puts "-" * 60
486
+ puts format("%-6s %-10s %s", "OLD", "NEW", "TITLE")
487
+ puts "-" * 60
488
+
489
+ intents.values.sort_by { |i| i.folgezettel_id.chars.map { |c| c.match?(/\d/) ? [0, c.to_i] : [1, c.ord] }.flatten }.each do |intent|
490
+ puts format("%-6s %-10s %s", intent.old_id, intent.folgezettel_id, intent.title[0..50])
491
+ end
492
+ puts
493
+
494
+ # Build mappings
495
+ mappings = build_mappings(intents)
496
+
497
+ if dry_run
498
+ puts "DRY RUN — no changes will be made."
499
+ puts
500
+ puts "Phase 4: File content updates..."
501
+ update_file_contents(store_path, intents, mappings, dry_run: true)
502
+ puts
503
+ puts "Phase 5: Intent file renames..."
504
+ rename_intent_files(store_path, intents, dry_run: true)
505
+ puts
506
+ puts "Phase 6: Directory renames..."
507
+ rename_directories(store_path, intents, mappings, dry_run: true)
508
+ puts
509
+ puts "Phase 7: INDEX.md updates..."
510
+ rewrite_index(store_root, intents, mappings, dry_run: true)
511
+ puts
512
+ puts "Phase 8: projects.yml updates..."
513
+ update_projects_yml(store_root, mappings, dry_run: true)
514
+ else
515
+ puts "Phase 4: Updating file contents..."
516
+ update_file_contents(store_path, intents, mappings, dry_run: false)
517
+ puts
518
+ puts "Phase 5: Renaming intent files..."
519
+ rename_intent_files(store_path, intents, dry_run: false)
520
+ puts
521
+ puts "Phase 6: Renaming directories..."
522
+ rename_directories(store_path, intents, mappings, dry_run: false)
523
+ puts
524
+ puts "Phase 7: Rewriting INDEX.md..."
525
+ rewrite_index(store_root, intents, mappings, dry_run: false)
526
+ puts
527
+ puts "Phase 8: Updating projects.yml..."
528
+ update_projects_yml(store_root, mappings, dry_run: false)
529
+ end
530
+
531
+ puts
532
+ puts "Done."
533
+ end
534
+
535
+ main
@@ -0,0 +1,96 @@
1
+ #!/bin/bash
2
+ # Migrate a per-project .plastic/ store to the global ~/.plastic/ store.
3
+ # Usage: migrate-to-global [project-path]
4
+ #
5
+ # This script:
6
+ # 1. Copies store/ and INDEX.md to ~/.plastic/
7
+ # 2. Registers the project in projects.yml
8
+ # 3. Rewrites relative links to [[ID]] wikilinks
9
+ # 4. Commits in ~/.plastic/
10
+
11
+ set -e
12
+
13
+ PROJECT_PATH="${1:-.}"
14
+ PROJECT_PATH="$(cd "$PROJECT_PATH" && pwd)"
15
+ PROJECT_SLUG="$(basename "$PROJECT_PATH")"
16
+ GLOBAL_ROOT="$HOME/.plastic"
17
+ LOCAL_PLASTIC="$PROJECT_PATH/.plastic"
18
+
19
+ if [ ! -d "$GLOBAL_ROOT" ]; then
20
+ echo "Error: Global Plastic not installed. Run /plastic:install first." >&2
21
+ exit 1
22
+ fi
23
+
24
+ if [ ! -d "$LOCAL_PLASTIC/store" ]; then
25
+ echo "Error: No .plastic/store/ found at $PROJECT_PATH" >&2
26
+ exit 1
27
+ fi
28
+
29
+ echo "Migrating $PROJECT_SLUG to global store at $GLOBAL_ROOT..."
30
+
31
+ # Copy store contents
32
+ echo " Copying intents..."
33
+ cp -R "$LOCAL_PLASTIC/store/"* "$GLOBAL_ROOT/store/" 2>/dev/null || true
34
+
35
+ # Copy INDEX.md (merge manually if global already has content)
36
+ if [ ! -s "$GLOBAL_ROOT/INDEX.md" ] || grep -q "^(no active intents)" "$GLOBAL_ROOT/INDEX.md"; then
37
+ echo " Copying INDEX.md..."
38
+ cp "$LOCAL_PLASTIC/INDEX.md" "$GLOBAL_ROOT/INDEX.md"
39
+ else
40
+ echo " WARNING: Global INDEX.md already has content. Manual merge needed."
41
+ echo " Local INDEX.md saved as $GLOBAL_ROOT/INDEX.md.migrated"
42
+ cp "$LOCAL_PLASTIC/INDEX.md" "$GLOBAL_ROOT/INDEX.md.migrated"
43
+ fi
44
+
45
+ # Rewrite relative links to wikilinks
46
+ echo " Rewriting links to [[ID]] format..."
47
+ ruby <<'RUBY'
48
+ # Find intent files (named {ID}.md, the primary file in each intent directory)
49
+ Dir.glob(File.expand_path("~/.plastic/store/*/")).each do |dir|
50
+ id = File.basename(dir).split("--").first
51
+ f = File.join(dir, "#{id}.md")
52
+ next unless File.exist?(f)
53
+ content = File.read(f)
54
+ # Match patterns like [name](../ID--slug/old-file.md)
55
+ updated = content.gsub(/\[([^\]]+)\]\(\.\.\/([^\/]+)\/[^)]+\.md\)/) do
56
+ name = $1
57
+ dir_name = $2
58
+ target_id = dir_name.split("--").first
59
+ "[[#{target_id}]] #{name}"
60
+ end
61
+ if updated != content
62
+ File.write(f, updated)
63
+ puts " Updated: #{File.basename(dir)}"
64
+ end
65
+ end
66
+ RUBY
67
+
68
+ # Register project in projects.yml
69
+ echo " Registering project..."
70
+ REMOTE=$(cd "$PROJECT_PATH" && git remote get-url origin 2>/dev/null || echo "null")
71
+ ruby -r yaml <<RUBY
72
+ path = File.expand_path("$GLOBAL_ROOT/projects.yml")
73
+ data = YAML.safe_load(File.read(path)) rescue {}
74
+ data["projects"] ||= {}
75
+ data["projects"]["$PROJECT_SLUG"] = {
76
+ "path" => "$PROJECT_PATH",
77
+ "parent" => nil,
78
+ "remote" => "$REMOTE" == "null" ? nil : "$REMOTE",
79
+ "registered" => Date.today.to_s,
80
+ "status" => "active"
81
+ }
82
+ File.write(path, YAML.dump(data))
83
+ RUBY
84
+
85
+ # Commit
86
+ echo " Committing..."
87
+ cd "$GLOBAL_ROOT"
88
+ git add .
89
+ git commit -m "feat: migrate $PROJECT_SLUG intents to global store"
90
+
91
+ echo "Done! Intents migrated to $GLOBAL_ROOT"
92
+ echo ""
93
+ echo "Next steps:"
94
+ echo " 1. Review ~/.plastic/INDEX.md"
95
+ echo " 2. Remove $LOCAL_PLASTIC/store/ and $LOCAL_PLASTIC/INDEX.md from the project"
96
+ echo " 3. Keep $LOCAL_PLASTIC/plugin/ if using as development copy"