@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.
- package/LICENSE +21 -0
- package/PLASTIC.md +534 -0
- package/README.md +88 -0
- package/agents/future-intent-researcher.md +38 -0
- package/agents/intent-curator.md +40 -0
- package/bin/install.js +29 -0
- package/deprecations.yml +23 -0
- package/hooks/check-update +42 -0
- package/hooks/continue +31 -0
- package/hooks/future-intent-check +25 -0
- package/hooks/gate-check +10 -0
- package/hooks/hooks.json +78 -0
- package/hooks/run-hook +7 -0
- package/hooks/savepoint +6 -0
- package/hooks/session-start +9 -0
- package/hooks/statusline +16 -0
- package/package.json +43 -0
- package/scripts/folgezettel-id +40 -0
- package/scripts/hash-intent +29 -0
- package/scripts/hook-continue +130 -0
- package/scripts/hook-future-intent-check +90 -0
- package/scripts/hook-gate-check +136 -0
- package/scripts/hook-session-start +224 -0
- package/scripts/install.rb +474 -0
- package/scripts/lib/bridge.rb +139 -0
- package/scripts/migrate-folgezettel +535 -0
- package/scripts/migrate-to-global +96 -0
- package/scripts/read-config +129 -0
- package/skills/auto/SKILL.md +127 -0
- package/skills/brainstorming-grill-me/SKILL.md +105 -0
- package/skills/continuing/SKILL.md +104 -0
- package/skills/creating-intent/SKILL.md +122 -0
- package/skills/creating-project/SKILL.md +166 -0
- package/skills/executing-plan/SKILL.md +120 -0
- package/skills/executing-plan/code-quality-reviewer-prompt.md +32 -0
- package/skills/executing-plan/implementer-prompt.md +42 -0
- package/skills/executing-plan/spec-reviewer-prompt.md +27 -0
- package/skills/install/SKILL.md +134 -0
- package/skills/intent-curator/SKILL.md +41 -0
- package/skills/linking-intents/SKILL.md +72 -0
- package/skills/managing-index/SKILL.md +66 -0
- package/skills/managing-index/references/zettelkasten-linking.md +27 -0
- package/skills/releasing/SKILL.md +124 -0
- package/skills/savepoint/SKILL.md +57 -0
- package/skills/uninstall/SKILL.md +48 -0
- package/skills/update/SKILL.md +69 -0
- package/templates/agents.md +46 -0
- package/templates/checklist.md +11 -0
- package/templates/config.yml +13 -0
- package/templates/index.md +13 -0
- package/templates/intent.md +24 -0
- package/templates/plan.md +11 -0
- package/templates/projects.yml +3 -0
- 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"
|