@zalom/plastic 1.0.0-beta.8 → 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.
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # project-links — project each intent's corrected sources/chain graph into its
6
+ # canonical I5 `## Links` section, store-wide across the global, plastic, and
7
+ # knowdb stores (intent 72). Deterministic and idempotent: a second run over a
8
+ # projected store changes zero files. Touches ONLY the `## Links` section of each
9
+ # intent file; frontmatter and every other section stay byte-identical.
10
+ #
11
+ # Usage:
12
+ # project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH]
13
+ #
14
+ # Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
15
+ # lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
16
+ # frontmatter (it NEVER writes frontmatter, that is intent 49's domain) and the
17
+ # on-disk `id--slug` directory basenames to build the cross-store resolver.
18
+ # Never pushes ~/.plastic (no git ops here).
19
+
20
+ require "time"
21
+ require "fileutils"
22
+
23
+ require_relative "lib/intent_validator"
24
+ require_relative "lib/graph_rebuild"
25
+ require_relative "lib/links_projection"
26
+ require_relative "lib/links_section"
27
+
28
+ class ProjectLinks
29
+ DEFAULT_HOME = File.join(Dir.home, ".plastic")
30
+
31
+ # The 72 intent dir audit destination (relative to plastic_home).
32
+ DEFAULT_AUDIT_REL =
33
+ "projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
34
+
35
+ def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
36
+ @plastic_home = plastic_home
37
+ @dry_run = dry_run
38
+
39
+ # A dry run must NOT stomp the canonical audit (humans run --dry-run to review
40
+ # the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
41
+ # sibling, leaving the canonical real-run audit untouched. An explicit
42
+ # --audit-path is always honored verbatim (tests inject it). Mirrors
43
+ # RebuildGraph's discipline exactly.
44
+ canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
45
+ @audit_path =
46
+ if audit_path
47
+ audit_path
48
+ elsif dry_run
49
+ canonical.sub(/\.md\z/, ".dry-run.md")
50
+ else
51
+ canonical
52
+ end
53
+ end
54
+
55
+ attr_reader :plastic_home, :dry_run, :audit_path
56
+
57
+ # The three in-scope stores, each as { key:, root:, store:, index: }.
58
+ def stores
59
+ list = []
60
+ global_store = File.join(plastic_home, "store")
61
+ if File.directory?(global_store)
62
+ list << { key: "global", root: plastic_home, store: global_store,
63
+ index: File.join(plastic_home, "INDEX.md") }
64
+ end
65
+
66
+ %w[plastic knowdb].each do |slug|
67
+ root = File.join(plastic_home, "projects", slug)
68
+ store = File.join(root, "store")
69
+ next unless File.directory?(store)
70
+
71
+ list << { key: "project:#{slug}", root: root, store: store,
72
+ index: File.join(root, "INDEX.md") }
73
+ end
74
+ list
75
+ end
76
+
77
+ # { id => { sources:, chain:, basename:, label:, path: } } for one store.
78
+ def load_nodes(store_dir)
79
+ nodes = {}
80
+ Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
81
+ dir = File.join(store_dir, entry)
82
+ next unless File.directory?(dir)
83
+
84
+ md = File.join(dir, "#{entry}.md")
85
+ next unless File.exist?(md)
86
+
87
+ fm = IntentValidator.parse_frontmatter(md)
88
+ next unless fm.is_a?(Hash) && fm["id"]
89
+
90
+ nodes[fm["id"].to_s] = {
91
+ sources: Array(fm["sources"]).map(&:to_s),
92
+ chain: Array(fm["chain"]).map(&:to_s),
93
+ basename: entry, # the on-disk `id--slug` directory name
94
+ label: fm["intent"].to_s.strip,
95
+ path: md,
96
+ }
97
+ end
98
+ nodes
99
+ end
100
+
101
+ def run
102
+ store_list = stores
103
+ nodes_by_store = {}
104
+ index_texts = {}
105
+ store_index = {}
106
+ node_index = {}
107
+
108
+ store_list.each do |s|
109
+ key = s[:key]
110
+ nodes_by_store[key] = load_nodes(s[:store])
111
+ index_texts[key] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
112
+ store_index[key] = nodes_by_store[key].keys
113
+ node_index[key] = nodes_by_store[key].transform_values do |v|
114
+ { basename: v[:basename], label: v[:label] }
115
+ end
116
+ end
117
+
118
+ relocation_map = GraphRebuild.build_relocation_map(index_texts)
119
+
120
+ results = {}
121
+ store_list.each do |s|
122
+ key = s[:key]
123
+ results[key] = project_store(
124
+ key, nodes_by_store[key],
125
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
126
+ )
127
+ end
128
+
129
+ emit_audit(store_list, results)
130
+ results
131
+ end
132
+
133
+ # Project every intent in ONE store. Returns
134
+ # { entries: [ {id:, status:, before:, after:, error:} ], counts: {...} }.
135
+ def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
136
+ entries = []
137
+ nodes.each do |id, node|
138
+ resolve = ->(ref) do
139
+ LinksProjection.resolve_ref_projection(
140
+ ref, referer_store: referer_store,
141
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
142
+ )
143
+ end
144
+
145
+ content = File.read(node[:path])
146
+
147
+ begin
148
+ had_links = LinksSection.links_heading?(IntentValidator.body_of(content))
149
+ section_text = LinksProjection.section(
150
+ sources: node[:sources], chain: node[:chain], resolve: resolve
151
+ )
152
+ updated = LinksSection.rewrite(content, section_text)
153
+ rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
154
+ entries << { id: id, status: :failed, error: e.message }
155
+ next
156
+ end
157
+
158
+ if updated == content
159
+ entries << { id: id, status: :unchanged }
160
+ next
161
+ end
162
+
163
+ status = had_links ? :regenerated : :added
164
+ File.write(node[:path], updated) unless dry_run
165
+ entries << { id: id, status: status,
166
+ before: extract_links(content), after: section_text }
167
+ end
168
+
169
+ counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
170
+ { entries: entries, counts: counts }
171
+ end
172
+
173
+ # Pull the current REAL `## Links` section text (fence-aware) from a file's
174
+ # content, for the audit sample. Returns "" when absent. Delegates to the shared
175
+ # LinksSection.extract_section so the audit, the rewriter, and the doctor check
176
+ # all agree on the section location (and never match a heading inside an example
177
+ # code fence).
178
+ def extract_links(content)
179
+ LinksSection.extract_section(IntentValidator.body_of(content))
180
+ rescue LinksSection::AmbiguousLinks
181
+ "(ambiguous: multiple ## Links headings)"
182
+ end
183
+
184
+ # Always write the audit (even in dry-run, so the human reviews the plan).
185
+ def emit_audit(store_list, results)
186
+ text = render_audit(store_list, results)
187
+ FileUtils.mkdir_p(File.dirname(audit_path))
188
+ File.write(audit_path, text)
189
+ text
190
+ end
191
+
192
+ STATUS_ORDER = %i[regenerated added unchanged failed].freeze
193
+ STATUS_LABELS = {
194
+ regenerated: "Regenerated (had a ## Links, content changed)",
195
+ added: "Added (no ## Links section, one inserted)",
196
+ unchanged: "Unchanged (already canonical)",
197
+ failed: "FAILED (resolver miss, NOT written)",
198
+ }.freeze
199
+
200
+ def render_audit(store_list, results)
201
+ lines = []
202
+ lines << "# Audit: store-wide ## Links projection (intent 72)"
203
+ lines << ""
204
+ lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
205
+ lines << ""
206
+
207
+ total = STATUS_ORDER.to_h do |st|
208
+ [st, store_list.sum { |s| results[s[:key]][:counts][st] }]
209
+ end
210
+ lines << "Totals across all stores: " \
211
+ "regenerated #{total[:regenerated]}, added #{total[:added]}, " \
212
+ "unchanged #{total[:unchanged]}, failed #{total[:failed]}."
213
+ lines << ""
214
+
215
+ store_list.each do |s|
216
+ key = s[:key]
217
+ res = results[key]
218
+ counts = res[:counts]
219
+ lines << "## #{key}"
220
+ lines << ""
221
+ lines << "Regenerated #{counts[:regenerated]}, added #{counts[:added]}, " \
222
+ "unchanged #{counts[:unchanged]}, failed #{counts[:failed]}."
223
+ lines << ""
224
+
225
+ failed = res[:entries].select { |e| e[:status] == :failed }
226
+ unless failed.empty?
227
+ lines << "### FAILED (#{failed.size})"
228
+ failed.each { |e| lines << "- #{e[:id]}: #{e[:error]}" }
229
+ lines << ""
230
+ end
231
+
232
+ sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
233
+ next if sample.empty?
234
+
235
+ lines << "### Sample before/after (first #{sample.size})"
236
+ sample.each do |e|
237
+ lines << "- #{e[:id]} (#{e[:status]}):"
238
+ lines << " - BEFORE:"
239
+ block_lines(e[:before]).each { |l| lines << " #{l}" }
240
+ lines << " - AFTER:"
241
+ block_lines(e[:after]).each { |l| lines << " #{l}" }
242
+ end
243
+ lines << ""
244
+ end
245
+
246
+ lines.join("\n") + "\n"
247
+ end
248
+
249
+ def block_lines(text)
250
+ s = text.to_s.strip
251
+ return ["(none)"] if s.empty?
252
+
253
+ s.lines.map(&:rstrip)
254
+ end
255
+
256
+ # True iff any intent failed (resolver miss).
257
+ def any_failed?(results)
258
+ results.values.any? { |r| r[:counts][:failed].to_i.positive? }
259
+ end
260
+ end
261
+
262
+ if $PROGRAM_NAME == __FILE__
263
+ home = ProjectLinks::DEFAULT_HOME
264
+ dry = false
265
+ audit = nil
266
+ i = 0
267
+ while i < ARGV.length
268
+ case ARGV[i]
269
+ when "--plastic-home" then home = ARGV[i + 1]; i += 2
270
+ when "--dry-run" then dry = true; i += 1
271
+ when "--audit-path" then audit = ARGV[i + 1]; i += 2
272
+ else i += 1
273
+ end
274
+ end
275
+
276
+ tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit)
277
+ results = tool.run
278
+ totals = ProjectLinks::STATUS_ORDER.to_h do |st|
279
+ [st, results.values.sum { |r| r[:counts][st] }]
280
+ end
281
+ puts "project-links #{dry ? "DRY RUN" : "applied"}: " \
282
+ "regenerated #{totals[:regenerated]}, added #{totals[:added]}, " \
283
+ "unchanged #{totals[:unchanged]}, failed #{totals[:failed]} " \
284
+ "across #{results.size} store(s)."
285
+ puts "Audit: #{tool.audit_path}"
286
+ exit 1 if tool.any_failed?(results)
287
+ end
@@ -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: all `sources`
46
- first (top, named), then all `chain` (named), as `[[id]]` wikilinks plus a
47
- short label. Counterpart to the frontmatter `sources` / `chain` edges, for
48
- Obsidian graph navigation.
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) wikilinks in `## Links` section
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