@zalom/plastic 1.0.0-beta.7 → 1.0.0-beta.9
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/PLASTIC.md +9 -2
- package/hooks/statusline +27 -2
- package/package.json +1 -1
- package/scripts/doctor.rb +172 -0
- package/scripts/lib/bridge.rb +11 -2
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +4 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/new-intent +129 -28
- package/scripts/project-links +287 -0
- package/scripts/rebuild-graph +244 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
|
|
6
|
+
# global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
|
|
7
|
+
# one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
|
|
8
|
+
# in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
|
|
9
|
+
# relocation map (relocation wins over coincidental id reuse). Emits a
|
|
10
|
+
# before/after audit, then writes minimal style-preserving frontmatter.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
14
|
+
#
|
|
15
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
|
|
16
|
+
# lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
|
|
17
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
18
|
+
|
|
19
|
+
require "yaml"
|
|
20
|
+
require "date"
|
|
21
|
+
require "time"
|
|
22
|
+
require "fileutils"
|
|
23
|
+
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/frontmatter_writer"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
class RebuildGraph
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 49 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"
|
|
34
|
+
|
|
35
|
+
KIND_LABELS = {
|
|
36
|
+
dedupe: "Dedupes",
|
|
37
|
+
i3: "I3 resolutions (kept in sources, dropped from chain)",
|
|
38
|
+
repoint: "Cross-store repoints",
|
|
39
|
+
collapse: "Cross-store collapses (to bare same-store id)",
|
|
40
|
+
drop: "Dropped dead refs",
|
|
41
|
+
i1_backlink: "I1 backlinks added",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
47
|
+
@plastic_home = plastic_home
|
|
48
|
+
@dry_run = dry_run
|
|
49
|
+
|
|
50
|
+
# A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
|
|
51
|
+
# to run --dry-run to review the plan). When no explicit --audit-path is given,
|
|
52
|
+
# a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
|
|
53
|
+
# real-run audit untouched. An explicit --audit-path is always honored verbatim
|
|
54
|
+
# (it is the caller's responsibility, and tests inject it).
|
|
55
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
56
|
+
@audit_path =
|
|
57
|
+
if audit_path
|
|
58
|
+
audit_path
|
|
59
|
+
elsif dry_run
|
|
60
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
61
|
+
else
|
|
62
|
+
canonical
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
67
|
+
|
|
68
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
69
|
+
# `root` is the dir holding INDEX.md; `store` is the intents dir.
|
|
70
|
+
def stores
|
|
71
|
+
list = []
|
|
72
|
+
global_store = File.join(plastic_home, "store")
|
|
73
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
74
|
+
index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
|
|
75
|
+
|
|
76
|
+
%w[plastic knowdb].each do |slug|
|
|
77
|
+
root = File.join(plastic_home, "projects", slug)
|
|
78
|
+
store = File.join(root, "store")
|
|
79
|
+
next unless File.directory?(store)
|
|
80
|
+
|
|
81
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
82
|
+
index: File.join(root, "INDEX.md") }
|
|
83
|
+
end
|
|
84
|
+
list
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { id => { sources:, chain:, path: } } for one store.
|
|
88
|
+
def load_nodes(store_dir)
|
|
89
|
+
nodes = {}
|
|
90
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
91
|
+
dir = File.join(store_dir, entry)
|
|
92
|
+
next unless File.directory?(dir)
|
|
93
|
+
|
|
94
|
+
md = File.join(dir, "#{entry}.md")
|
|
95
|
+
next unless File.exist?(md)
|
|
96
|
+
|
|
97
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
98
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
99
|
+
|
|
100
|
+
nodes[fm["id"].to_s] = {
|
|
101
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
102
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
103
|
+
path: md,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
nodes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
store_list = stores
|
|
111
|
+
nodes_by_store = {}
|
|
112
|
+
index_texts = {}
|
|
113
|
+
store_index = {}
|
|
114
|
+
|
|
115
|
+
store_list.each do |s|
|
|
116
|
+
nodes_by_store[s[:key]] = load_nodes(s[:store])
|
|
117
|
+
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
118
|
+
store_index[s[:key]] = nodes_by_store[s[:key]].keys
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
122
|
+
|
|
123
|
+
results = {}
|
|
124
|
+
store_list.each do |s|
|
|
125
|
+
key = s[:key]
|
|
126
|
+
input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
|
|
127
|
+
results[key] = GraphRebuild.rebuild_store(
|
|
128
|
+
input,
|
|
129
|
+
referer_store: key,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
write_back(store_list, nodes_by_store, results) unless dry_run
|
|
136
|
+
emit_audit(store_list, nodes_by_store, results)
|
|
137
|
+
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Write changed frontmatter back via the minimal style-preserving writer.
|
|
142
|
+
def write_back(store_list, nodes_by_store, results)
|
|
143
|
+
store_list.each do |s|
|
|
144
|
+
key = s[:key]
|
|
145
|
+
new_nodes = results[key][:nodes]
|
|
146
|
+
nodes_by_store[key].each do |id, original|
|
|
147
|
+
rebuilt = new_nodes[id]
|
|
148
|
+
next if rebuilt.nil?
|
|
149
|
+
next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]
|
|
150
|
+
|
|
151
|
+
content = File.read(original[:path])
|
|
152
|
+
updated = FrontmatterWriter.rewrite_arrays(content,
|
|
153
|
+
sources: rebuilt[:sources],
|
|
154
|
+
chain: rebuilt[:chain])
|
|
155
|
+
File.write(original[:path], updated) if updated != content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Render the audit and write it (always, even in dry-run, so the human reviews
|
|
161
|
+
# the dry-run plan). Returns the rendered string.
|
|
162
|
+
def emit_audit(store_list, _nodes_by_store, results)
|
|
163
|
+
text = render_audit(store_list, results)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
165
|
+
File.write(audit_path, text)
|
|
166
|
+
text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# PURE-ish formatter (string from results). Per-store, grouped by kind.
|
|
170
|
+
def render_audit(store_list, results)
|
|
171
|
+
total = store_list.sum { |s| results[s[:key]][:changes].size }
|
|
172
|
+
lines = []
|
|
173
|
+
lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
176
|
+
lines << ""
|
|
177
|
+
lines << "Total changes across all stores: #{total}"
|
|
178
|
+
lines << ""
|
|
179
|
+
|
|
180
|
+
store_list.each do |s|
|
|
181
|
+
key = s[:key]
|
|
182
|
+
changes = results[key][:changes]
|
|
183
|
+
lines << "## #{key}"
|
|
184
|
+
lines << ""
|
|
185
|
+
if changes.empty?
|
|
186
|
+
lines << "No changes."
|
|
187
|
+
lines << ""
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
KIND_ORDER.each do |kind|
|
|
192
|
+
group = changes.select { |c| c[:kind] == kind }
|
|
193
|
+
next if group.empty?
|
|
194
|
+
|
|
195
|
+
lines << "### #{KIND_LABELS[kind]} (#{group.size})"
|
|
196
|
+
group.each { |c| lines << "- #{format_change(c)}" }
|
|
197
|
+
lines << ""
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
lines.join("\n") + "\n"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_change(c)
|
|
205
|
+
case c[:kind]
|
|
206
|
+
when :dedupe
|
|
207
|
+
"#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
|
|
208
|
+
"chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
|
|
209
|
+
when :i3
|
|
210
|
+
"#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
|
|
211
|
+
when :repoint
|
|
212
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
|
|
213
|
+
when :collapse
|
|
214
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
|
|
215
|
+
when :drop
|
|
216
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
|
|
217
|
+
when :i1_backlink
|
|
218
|
+
"#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
|
|
219
|
+
else
|
|
220
|
+
c.inspect
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if $PROGRAM_NAME == __FILE__
|
|
226
|
+
home = RebuildGraph::DEFAULT_HOME
|
|
227
|
+
dry = false
|
|
228
|
+
audit = nil
|
|
229
|
+
i = 0
|
|
230
|
+
while i < ARGV.length
|
|
231
|
+
case ARGV[i]
|
|
232
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
233
|
+
when "--dry-run" then dry = true; i += 1
|
|
234
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
235
|
+
else i += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
240
|
+
results = tool.run
|
|
241
|
+
total = results.values.sum { |r| r[:changes].size }
|
|
242
|
+
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
243
|
+
puts "Audit: #{tool.audit_path}"
|
|
244
|
+
end
|
|
@@ -42,10 +42,15 @@ field.
|
|
|
42
42
|
|
|
43
43
|
## `## Links`
|
|
44
44
|
|
|
45
|
-
The human-readable projection of the local knowledge graph
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
The human-readable projection of the local knowledge graph, mirroring the
|
|
46
|
+
frontmatter exactly. Each entry is `- [[id--slug|<target's full intent: text>]]`,
|
|
47
|
+
a clickable `id--slug` wikilink target with the target intent's full `intent:`
|
|
48
|
+
text as the label (cross-store targets render
|
|
49
|
+
`- [[store:id--slug|<target's full intent: text>]]`). Ordering is mandatory: all
|
|
50
|
+
`sources` first (top), then all `chain`, frontmatter order preserved within each
|
|
51
|
+
group. Sources never appear at the end. No source/chain tags, no sub-grouping. An
|
|
52
|
+
intent with empty `sources` and `chain` carries the empty-state comment. Counterpart
|
|
53
|
+
to the frontmatter `sources` / `chain` edges, for Obsidian graph navigation.
|
|
49
54
|
|
|
50
55
|
## Conventions — Filesystem as Schema
|
|
51
56
|
|
|
@@ -10,6 +10,13 @@ Plastic implements three Zettelkasten structures:
|
|
|
10
10
|
|
|
11
11
|
INDEX.md is a structure note (hub), not a table of contents.
|
|
12
12
|
|
|
13
|
+
`## Links` mirrors the frontmatter graph exactly. Each entry is
|
|
14
|
+
`- [[id--slug|<target's full intent: text>]]` (cross-store: `- [[store:id--slug|...]]`),
|
|
15
|
+
a clickable `id--slug` target with the target's full `intent:` text as the label.
|
|
16
|
+
Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
|
|
17
|
+
preserved within each group. Sources never appear at the end. No source/chain tags, no
|
|
18
|
+
sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
|
|
19
|
+
|
|
13
20
|
## Folgezettel IDs
|
|
14
21
|
|
|
15
22
|
IDs encode lineage using Luhmann's alternating convention:
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
## Three Connection Types (Ranked)
|
|
10
10
|
|
|
11
|
-
1. **Direct links** (strongest)
|
|
11
|
+
1. **Direct links** (strongest): wikilinks in the `## Links` section, the projection of the
|
|
12
|
+
frontmatter graph. Each entry is `- [[id--slug|<target's full intent: text>]]` (cross-store:
|
|
13
|
+
`- [[store:id--slug|...]]`), a clickable `id--slug` target labeled with the target's full
|
|
14
|
+
`intent:` text. Ordering is mandatory: all `sources` first (top), then all `chain`,
|
|
15
|
+
frontmatter order preserved within each group. Sources never appear at the end. No
|
|
16
|
+
source/chain tags, no sub-grouping.
|
|
12
17
|
2. **Sources/Chain** (knowledge graph): `sources` = direct ascendants this was created from (formation, acyclic, must-load); `chain` = forward continuations and related successors (may cycle, lighter context). See `docs/concepts/how-plastic-sources-and-chains-intents.md` for the full model.
|
|
13
18
|
3. **Tags** (weakest) — shared tags, `project-<name>` for project membership
|
|
14
19
|
|