@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21

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 (82) hide show
  1. package/PLASTIC.md +131 -7
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-enforcer.md +1 -1
  4. package/agents/plastic-executor.md +11 -1
  5. package/agents/plastic-intent-curator.md +7 -5
  6. package/agents/plastic-planner.md +11 -1
  7. package/agents/plastic-spec-specialist.md +9 -1
  8. package/hooks/hooks.json +20 -0
  9. package/hooks/retrieval-gate +10 -0
  10. package/hooks/savepoint-pre +10 -0
  11. package/hooks/statusline +150 -41
  12. package/package.json +1 -1
  13. package/scripts/agent-report +163 -0
  14. package/scripts/doctor.rb +172 -0
  15. package/scripts/hook-auto-arm +1 -1
  16. package/scripts/hook-bash-gate +2 -2
  17. package/scripts/hook-code-gate +11 -6
  18. package/scripts/hook-create-gate +2 -2
  19. package/scripts/hook-gate-check +14 -23
  20. package/scripts/hook-retrieval-gate +136 -0
  21. package/scripts/hook-savepoint-pre +32 -0
  22. package/scripts/hook-session-start +1 -1
  23. package/scripts/insight-append +51 -0
  24. package/scripts/lib/bridge.rb +374 -34
  25. package/scripts/lib/frontmatter_writer.rb +130 -0
  26. package/scripts/lib/graph_rebuild.rb +328 -0
  27. package/scripts/lib/insights.rb +86 -0
  28. package/scripts/lib/installer_core.rb +23 -0
  29. package/scripts/lib/link_suggestions.rb +322 -0
  30. package/scripts/lib/links_projection.rb +160 -0
  31. package/scripts/lib/links_section.rb +207 -0
  32. package/scripts/lib/power_tools.rb +76 -0
  33. package/scripts/lib/qmd_hook.rb +38 -25
  34. package/scripts/lib/qmd_sync.rb +36 -0
  35. package/scripts/lib/retrieval_gate.rb +211 -0
  36. package/scripts/lib/worktree.rb +409 -0
  37. package/scripts/link-suggest +211 -0
  38. package/scripts/new-intent +138 -29
  39. package/scripts/project-links +287 -0
  40. package/scripts/qmd-sync +50 -3
  41. package/scripts/rebuild-graph +244 -0
  42. package/scripts/spawn-preamble +26 -1
  43. package/skills/auto/SKILL.md +58 -11
  44. package/skills/auto/evals/evals.json +48 -0
  45. package/skills/auto/references/agent-architecture.md +27 -4
  46. package/skills/auto/references/agent-report-contract.md +121 -0
  47. package/skills/brainstorming/SKILL.md +1 -0
  48. package/skills/brainstorming/evals/evals.json +22 -0
  49. package/skills/continuing/SKILL.md +30 -8
  50. package/skills/continuing/evals/evals.json +9 -0
  51. package/skills/creating-intent/SKILL.md +16 -2
  52. package/skills/creating-intent/evals/evals.json +16 -0
  53. package/skills/creating-intent/references/lifecycle.md +9 -4
  54. package/skills/creating-skills/SKILL.md +65 -0
  55. package/skills/creating-skills/evals/evals.json +108 -0
  56. package/skills/creating-skills/references/agents.md +168 -0
  57. package/skills/creating-skills/references/evals.md +41 -0
  58. package/skills/creating-skills/references/hooks.md +248 -0
  59. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  60. package/skills/creating-skills/references/scripts.md +166 -0
  61. package/skills/creating-skills/references/skills.md +165 -0
  62. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  63. package/skills/dashboard/SKILL.md +5 -0
  64. package/skills/dashboard/evals/evals.json +22 -0
  65. package/skills/executing-plan/SKILL.md +2 -2
  66. package/skills/humanizer/SKILL.md +39 -0
  67. package/skills/humanizer/evals/evals.json +70 -0
  68. package/skills/humanizer/references/always-on-snippet.md +9 -0
  69. package/skills/humanizer/references/examples.md +48 -0
  70. package/skills/intent-curator/SKILL.md +6 -1
  71. package/skills/intent-curator/evals/evals.json +22 -0
  72. package/skills/linking-intents/SKILL.md +54 -12
  73. package/skills/linking-intents/evals/evals.json +22 -0
  74. package/skills/linking-intents/references/zettelkasten.md +7 -0
  75. package/skills/managing-index/SKILL.md +8 -0
  76. package/skills/managing-index/evals/evals.json +22 -0
  77. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  78. package/skills/releasing/SKILL.md +32 -0
  79. package/skills/research/SKILL.md +8 -0
  80. package/skills/research/evals/evals.json +22 -0
  81. package/skills/writing-instructions/SKILL.md +0 -159
  82. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,322 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # LinkSuggestions - support links decided by CONTEXT INFLUENCE (intent 91, D7), not
5
+ # by shared files, shared symbols, or a topic-similarity score. A script cannot judge
6
+ # whether one intent's context influenced another (that is reasoning over meaning), so
7
+ # this helper does NOT grade. It only:
8
+ #
9
+ # - gather: DISCOVERY. Find candidate intents (via an injected candidate-finder)
10
+ # and surface each candidate's `## Intent` + `## Context` as the
11
+ # EVIDENCE an agent reads to judge influence.
12
+ # - record_edge: record a CONFIRMED `sources`/`chain` frontmatter edge AND append a
13
+ # line to `link-decisions.md` (utc, target, edge, rating, reason).
14
+ # Append-only, frontmatter block only, never a `## Links` line,
15
+ # never a delete. A no-op without confirm: true.
16
+ # - drift: flag any `## Links` wikilink with no matching frontmatter edge.
17
+ #
18
+ # Two systems frame this:
19
+ # - System for Brain: links are tiered by context influence (sources = foundational
20
+ # context that shaped creation; chain = the genuinely delivery-moving context, a
21
+ # HIGH bar; tags = loose theme grouping, not a link). The influence judgement is
22
+ # made by an agent, not here.
23
+ # - System for Work (Convention over Configuration): `## Links` is a derived view of
24
+ # `sources`/`chain`. This helper never authors a `## Links` line and never deletes.
25
+ #
26
+ # Design rules: all collaborators are injected via the constructor (the store dir, a
27
+ # filesystem reader, AND a candidate-finder). No eval, no ENV / global config seam.
28
+ # Reuses IntentValidator.parse_frontmatter; does NOT touch LinksProjection /
29
+ # LinksSection / project-links / doctor.
30
+
31
+ require "time"
32
+ require_relative "intent_validator"
33
+
34
+ class LinkSuggestions
35
+ # One discovery candidate plus the evidence an agent reads to judge influence.
36
+ Candidate = Struct.new(:id, :basename, :label, :intent, :context, keyword_init: true)
37
+
38
+ # A drift finding: a `## Links` ref with no matching `sources`/`chain` edge.
39
+ Drift = Struct.new(:ref, :detail, keyword_init: true)
40
+
41
+ RATINGS = %w[high medium low].freeze
42
+
43
+ # A minimal default filesystem reader. Injected so tests can substitute an
44
+ # in-memory map; the production path reads real files. No global state.
45
+ class DiskReader
46
+ def directory?(path)
47
+ File.directory?(path)
48
+ end
49
+
50
+ def children(path)
51
+ Dir.children(path)
52
+ end
53
+
54
+ def exist?(path)
55
+ File.exist?(path)
56
+ end
57
+
58
+ def read(path)
59
+ File.read(path)
60
+ end
61
+
62
+ def write(path, content)
63
+ File.write(path, content)
64
+ end
65
+ end
66
+
67
+ # Default candidate-finder: a cheap discovery net (NOT a grade) over the loaded
68
+ # nodes - candidates that share a non-project tag, share a `sources` parent/family,
69
+ # or sit at an adjacent id. The CLI may inject a QMD-backed finder instead. Either
70
+ # way this is DISCOVERY ONLY; influence is judged later by an agent.
71
+ class FamilyTagFinder
72
+ def call(subject_id, nodes)
73
+ subject = nodes[subject_id]
74
+ return [] unless subject
75
+
76
+ nodes.keys.select do |other_id|
77
+ next false if other_id == subject_id
78
+
79
+ other = nodes[other_id]
80
+ shares_tag?(subject, other) || shares_family?(subject, other) ||
81
+ adjacent_id?(subject_id, other_id)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def shares_tag?(subject, other)
88
+ !(link_tags(subject) & link_tags(other)).empty?
89
+ end
90
+
91
+ def link_tags(node)
92
+ node[:tags].reject { |t| t.start_with?("project-") }
93
+ end
94
+
95
+ def shares_family?(subject, other)
96
+ return true unless (subject[:sources] & other[:sources]).empty?
97
+
98
+ subject[:sources].include?(other[:id]) || other[:sources].include?(subject[:id])
99
+ end
100
+
101
+ # Adjacent ids (discovery hint only): equal non-numeric prefix with integers
102
+ # differing by 1 (9<->10, 99<->100, 90<->91), or a letter successor at the same
103
+ # depth (66a<->66b). This is NOT a grade; it only widens the candidate net.
104
+ def adjacent_id?(a, b)
105
+ return false if a == b || a.empty? || b.empty?
106
+
107
+ ma = a.match(/\A(.*?)(\d+)\z/)
108
+ mb = b.match(/\A(.*?)(\d+)\z/)
109
+ return (ma[2].to_i - mb[2].to_i).abs == 1 if ma && mb && ma[1] == mb[1]
110
+
111
+ return false unless a.length == b.length
112
+
113
+ a[0..-2] == b[0..-2] && a[-1].match?(/[a-z]/) && b[-1].match?(/[a-z]/) &&
114
+ (a[-1].succ == b[-1] || b[-1].succ == a[-1])
115
+ end
116
+ end
117
+
118
+ # store_dir - the directory holding `id--slug/` intent folders for ONE store.
119
+ # reader - injected filesystem collaborator (DiskReader by default).
120
+ # finder - injected candidate-finder responding to #call(subject_id, nodes).
121
+ def initialize(store_dir:, reader: DiskReader.new, finder: FamilyTagFinder.new)
122
+ @store_dir = store_dir
123
+ @reader = reader
124
+ @finder = finder
125
+ end
126
+
127
+ attr_reader :store_dir, :reader, :finder
128
+
129
+ # Load every intent in the store as a node Hash keyed by id:
130
+ # { id => { id:, basename:, label:, path:, dir:, sources:[], chain:[], tags:[],
131
+ # links_refs:[], intent:, context: } }
132
+ def load_nodes
133
+ nodes = {}
134
+ return nodes unless reader.directory?(store_dir)
135
+
136
+ reader.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
137
+ dir = File.join(store_dir, entry)
138
+ next unless reader.directory?(dir)
139
+
140
+ md = File.join(dir, "#{entry}.md")
141
+ next unless reader.exist?(md)
142
+
143
+ content = reader.read(md)
144
+ fm = IntentValidator.parse_frontmatter_text(content)
145
+ next unless fm.is_a?(Hash) && fm["id"]
146
+
147
+ id = fm["id"].to_s
148
+ body = IntentValidator.body_of(content)
149
+ nodes[id] = {
150
+ id: id,
151
+ basename: entry,
152
+ label: fm["intent"].to_s.strip,
153
+ path: md,
154
+ dir: dir,
155
+ sources: Array(fm["sources"]).map(&:to_s),
156
+ chain: Array(fm["chain"]).map(&:to_s),
157
+ tags: Array(fm["tags"]).map(&:to_s),
158
+ links_refs: links_refs(body),
159
+ intent: section_text(body, "Intent"),
160
+ context: section_text(body, "Context"),
161
+ }
162
+ end
163
+ nodes
164
+ end
165
+
166
+ # DISCOVERY. The candidate intents for `subject_id`, each carrying its Intent +
167
+ # Context as the evidence an agent reads to judge influence. No grading. Sorted by
168
+ # natural id order for stable output.
169
+ def gather(subject_id, nodes: load_nodes)
170
+ return [] unless nodes.key?(subject_id)
171
+
172
+ ids = finder.call(subject_id, nodes)
173
+ ids.uniq.sort_by { |id| natural_key(id) }.filter_map do |id|
174
+ node = nodes[id]
175
+ next unless node
176
+
177
+ Candidate.new(id: id, basename: node[:basename], label: node[:label],
178
+ intent: node[:intent], context: node[:context])
179
+ end
180
+ end
181
+
182
+ # Record a single CONFIRMED edge from `subject_id` to `target_id`:
183
+ # 1. append `target_id` to the subject's frontmatter `sources` or `chain`;
184
+ # 2. append a line to `link-decisions.md` in the subject dir capturing
185
+ # {utc, target, edge, rating, reason}.
186
+ # Append-only, frontmatter block only. NEVER writes a `## Links` line, NEVER
187
+ # deletes. A no-op (returns false) without confirm: true, so a default run mutates
188
+ # nothing. Returns true when it wrote, false when it declined or the edge existed.
189
+ def record_edge(subject_id, target_id, edge:, rating: nil, reason: nil,
190
+ confirm: false, now: Time.now, nodes: load_nodes)
191
+ return false unless confirm
192
+ return false unless %i[sources chain].include?(edge)
193
+
194
+ subject = nodes[subject_id]
195
+ return false unless subject
196
+ return false if subject[edge].include?(target_id)
197
+
198
+ content = reader.read(subject[:path])
199
+ updated = add_frontmatter_ref(content, edge.to_s, target_id)
200
+ return false if updated == content
201
+
202
+ reader.write(subject[:path], updated)
203
+ append_decision(subject, target_id, edge, rating, reason, now)
204
+ true
205
+ end
206
+
207
+ # DRIFT. The `## Links` refs on `subject_id` with no matching `sources`/`chain`
208
+ # frontmatter edge behind them. Fence-skipping is honored so a `[[id]]` inside an
209
+ # example code block is not flagged.
210
+ def drift(subject_id, nodes: load_nodes)
211
+ subject = nodes[subject_id]
212
+ return [] unless subject
213
+
214
+ edges = (subject[:sources] + subject[:chain]).map { |r| bare_ref(r) }
215
+ subject[:links_refs].reject { |r| edges.include?(bare_ref(r)) }.map do |ref|
216
+ Drift.new(ref: ref,
217
+ detail: "`## Links` references #{ref} with no sources/chain edge behind it")
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ # Append one decision line to `link-decisions.md` in the subject's dir, creating the
224
+ # ledger with a header when absent. Append-only; never rewrites prior lines.
225
+ def append_decision(subject, target_id, edge, rating, reason, now)
226
+ ledger = File.join(subject[:dir], "link-decisions.md")
227
+ stamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
228
+ line = "#{stamp} | #{target_id} | #{edge} | #{rating || "-"} | #{reason || "-"}"
229
+
230
+ if reader.exist?(ledger)
231
+ existing = reader.read(ledger)
232
+ reader.write(ledger, existing.rstrip + "\n" + line + "\n")
233
+ else
234
+ header = "# Link decisions (intent 91 D6): utc | target | edge | rating | reason\n\n"
235
+ reader.write(ledger, header + line + "\n")
236
+ end
237
+ end
238
+
239
+ # Natural sort key for ids so 2 sorts before 10 and 14a groups with 14.
240
+ def natural_key(id)
241
+ id.scan(/\d+|[a-z]+/).map { |part| part.match?(/\d/) ? [0, part.to_i, ""] : [1, 0, part] }
242
+ end
243
+
244
+ # Strip a `store:` prefix from a ref so a cross-store and same-store form compare.
245
+ def bare_ref(ref)
246
+ ref.to_s.include?(":") ? ref.to_s.split(":", 2).last : ref.to_s
247
+ end
248
+
249
+ # Extract bare-id wikilink refs from a body's `## Links` section. Matches
250
+ # `[[<id>--<slug>|...]]` and `[[<store>:<id>--<slug>|...]]`, yielding the bare id.
251
+ # Reads only the Links section so example fences elsewhere are not scanned.
252
+ def links_refs(body)
253
+ section = section_text(body, "Links")
254
+ return [] if section.empty?
255
+
256
+ section.scan(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/).filter_map do |match|
257
+ t = bare_ref(match.first.strip)
258
+ id = t.split("--", 2).first
259
+ id unless id.nil? || id.empty?
260
+ end.uniq
261
+ end
262
+
263
+ # The text under the first `## <heading>` heading, up to the next `## ` heading. A
264
+ # deliberately small reader since we only need the section content. Fence lines are
265
+ # skipped so a `## Links` (or any heading) inside an example block is ignored.
266
+ def section_text(body, heading)
267
+ lines = body.to_s.lines
268
+ out = []
269
+ capture = false
270
+ in_fence = false
271
+ lines.each do |line|
272
+ stripped = line.strip
273
+ if stripped.start_with?("```")
274
+ in_fence = !in_fence
275
+ next
276
+ end
277
+ next if in_fence
278
+
279
+ if stripped == "## #{heading}"
280
+ capture = true
281
+ next
282
+ end
283
+ break if capture && stripped.start_with?("## ")
284
+
285
+ out << line if capture
286
+ end
287
+ out.join.strip
288
+ end
289
+
290
+ # Append a ref to a frontmatter array (`sources` or `chain`), creating the key if
291
+ # absent. Touches ONLY the frontmatter block; the body (including `## Links`) is
292
+ # left byte-identical. Inline-flow arrays (`key: ["a", "b"]`) are extended in place;
293
+ # an absent key is inserted before the closing `---`. Never deletes.
294
+ def add_frontmatter_ref(content, key, ref)
295
+ return content unless content.start_with?("---")
296
+
297
+ parts = content.split("---", 3)
298
+ return content if parts.length < 3
299
+
300
+ fm = parts[1]
301
+ line_re = /^#{Regexp.escape(key)}:\s*(.*)$/
302
+ if fm =~ line_re
303
+ current = Regexp.last_match(1).strip
304
+ new_line = extend_flow_array(key, current, ref)
305
+ fm = fm.sub(line_re, new_line)
306
+ else
307
+ fm = fm.rstrip + "\n#{key}: [\"#{ref}\"]\n"
308
+ end
309
+ "---#{fm}---#{parts[2]}"
310
+ end
311
+
312
+ def extend_flow_array(key, current, ref)
313
+ if current.empty? || current == "[]"
314
+ %(#{key}: ["#{ref}"])
315
+ elsif current.start_with?("[") && current.end_with?("]")
316
+ inner = current[1..-2].strip
317
+ inner.empty? ? %(#{key}: ["#{ref}"]) : %(#{key}: [#{inner}, "#{ref}"])
318
+ else
319
+ %(#{key}: ["#{ref}"])
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,160 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # LinksProjection — pure logic that projects one intent's sources/chain graph into
5
+ # the canonical I5 `## Links` section text (intent 72). Mirrors the pure-module
6
+ # style of GraphRebuild: module-function helpers, no file IO, no eval, no
7
+ # ENV/global state. The IO shell (scripts/project-links) and doctor build the
8
+ # cross-store resolver and feed it here.
9
+ #
10
+ # Pinned canonical projection (the human's format call):
11
+ # 1. ORDERING (load-bearing): ALL sources first, in frontmatter order, THEN all
12
+ # chain, in frontmatter order. Sources can NEVER appear at the end. No group
13
+ # headings, no per-entry source/chain tags: the ordering carries the meaning.
14
+ # 2. ENTRY SHAPE: one list item `- [[<id>--<slug>|<target's full intent: text>]]`,
15
+ # where the wikilink TARGET is the target intent's resolvable `id--slug` file
16
+ # basename (so it clicks through in Obsidian) and the LABEL is the target's
17
+ # full `intent:` frontmatter text, whitespace-trimmed.
18
+ # 3. CROSS-STORE: a cross-store target renders
19
+ # `- [[<store>:<id>--<slug>|<target's full intent: text>]]`.
20
+ # 4. RESOLVER MISS: a ref that resolves to no intent raises UnresolvedRef. No
21
+ # bare-id, slug-less, or guessed link is ever emitted.
22
+ # 5. EMPTY-STATE: empty sources AND chain yields the heading plus a single
23
+ # explanatory comment.
24
+ module LinksProjection
25
+ module_function
26
+
27
+ HEADING = "## Links"
28
+ EMPTY_COMMENT = "<!-- No sources or chain; this intent has no graph edges to project. -->"
29
+
30
+ # Raised when a sources/chain ref resolves to no intent. Carries the offending
31
+ # ref so the IO shell can report it per-intent and skip the write.
32
+ class UnresolvedRef < StandardError
33
+ attr_reader :ref
34
+
35
+ def initialize(ref)
36
+ @ref = ref
37
+ super("unresolved sources/chain ref: #{ref.inspect}")
38
+ end
39
+ end
40
+
41
+ # PURE. Build the canonical `## Links` section text for one intent.
42
+ #
43
+ # sources — array of id / `store:id` ref strings, in frontmatter order
44
+ # chain — array of id / `store:id` ref strings, in frontmatter order
45
+ # resolve — a callable (`->(ref) { ... }`) mapping ONE ref to a Hash like
46
+ # { target: "<id>--<slug>", label: "<full intent: text>" } or
47
+ # { target: "<store>:<id>--<slug>", label: "<full intent: text>" }.
48
+ # Returning nil (or a Hash lacking :target) signals no target and
49
+ # raises UnresolvedRef. Keeping resolution injected keeps this module
50
+ # pure and hermetically testable with in-memory maps.
51
+ #
52
+ # Returns the full section text: the `## Links` heading line, one entry line per
53
+ # ref (sources first, then chain), and a single trailing newline. The empty case
54
+ # returns the heading + the empty-state comment + a single trailing newline.
55
+ def section(sources:, chain:, resolve:)
56
+ src = Array(sources).map(&:to_s)
57
+ chn = Array(chain).map(&:to_s)
58
+
59
+ # Resolve EVERY ref to its { target:, label: } first, then dedup by the RESOLVED
60
+ # target (not the raw ref string). This is load-bearing: the same intent may be
61
+ # referenced as a bare id in one group and as `store:id` in another (or via a
62
+ # relocation), which dedups identically only AFTER resolution. Sources win
63
+ # (formative edge), and frontmatter order is preserved within each group.
64
+ seen = {}
65
+ rendered = []
66
+ src.each { |ref| add_entry(ref, resolve, seen, rendered) }
67
+ chn.each { |ref| add_entry(ref, resolve, seen, rendered) }
68
+
69
+ return empty_section if rendered.empty?
70
+
71
+ (["#{HEADING}\n"] + rendered.map { |line| "#{line}\n" }).join
72
+ end
73
+
74
+ # PURE. The canonical empty-state section: heading + the single comment line.
75
+ def empty_section
76
+ "#{HEADING}\n#{EMPTY_COMMENT}\n"
77
+ end
78
+
79
+ # Resolve `ref`, render its entry, and append it to `rendered` UNLESS its resolved
80
+ # target was already emitted (dedup by resolved target, first-seen wins so sources
81
+ # precede chain). Mutates `seen` and `rendered`. Raises UnresolvedRef on a miss.
82
+ def add_entry(ref, resolve, seen, rendered)
83
+ target, label = resolve_entry(ref, resolve)
84
+ return if seen.key?(target)
85
+
86
+ seen[target] = true
87
+ rendered << "- [[#{target}|#{label}]]"
88
+ end
89
+
90
+ # PURE. Resolve one ref to [target, label]. Raises UnresolvedRef when the
91
+ # resolver returns nothing usable.
92
+ def resolve_entry(ref, resolve)
93
+ resolved = resolve.call(ref)
94
+ target = resolved.is_a?(Hash) ? resolved[:target] || resolved["target"] : nil
95
+ raise UnresolvedRef, ref if target.nil? || target.to_s.strip.empty?
96
+
97
+ label = (resolved[:label] || resolved["label"]).to_s.strip
98
+ [target.to_s, label]
99
+ end
100
+
101
+ # PURE. Render one entry line `- [[<target>|<label>]]` from a single ref. Kept for
102
+ # callers/tests that render one entry; #section uses add_entry for dedup.
103
+ def entry(ref, resolve)
104
+ target, label = resolve_entry(ref, resolve)
105
+ "- [[#{target}|#{label}]]"
106
+ end
107
+
108
+ # PURE. Resolve ONE sources/chain ref to its `{ target:, label: }` projection,
109
+ # given the in-memory cross-store maps. This is the single resolver definition
110
+ # shared by the IO shell (scripts/project-links) and the doctor check, so the two
111
+ # can never diverge.
112
+ #
113
+ # ref — "40" (same-store bare id) or "knowdb:1" (cross-store)
114
+ # referer_store — store_key of the intent carrying the ref ("global" / "project:<slug>")
115
+ # relocation_map — from GraphRebuild.build_relocation_map (spans all stores)
116
+ # store_index — { store_key => [bare ids present] } (spans all stores)
117
+ # node_index — { store_key => { id => { basename:, label: } } } (spans all stores)
118
+ #
119
+ # Returns { target:, label: } (target is `<id>--<slug>` for a same-store id, or
120
+ # `<slug>:<id>--<slug>` for a cross-store one), or nil when the ref resolves to no
121
+ # live intent (which makes #section / #entry raise UnresolvedRef).
122
+ #
123
+ # Uses GraphRebuild.resolve_ref so a relocation always wins over a coincidentally
124
+ # reused id (the `global:24` impostor hazard), exactly as the frontmatter rebuild
125
+ # and the cross-store doctor check do.
126
+ def resolve_ref_projection(ref, referer_store:, relocation_map:, store_index:, node_index:)
127
+ require_relative "graph_rebuild"
128
+
129
+ res = GraphRebuild.resolve_ref(ref, referer_store: referer_store,
130
+ relocation_map: relocation_map,
131
+ store_index: store_index)
132
+ case res[:status]
133
+ when :same_store
134
+ node = (node_index[referer_store] || {})[res[:id]]
135
+ return nil if node.nil?
136
+
137
+ { target: node[:basename], label: node[:label] }
138
+ when :cross_store
139
+ slug, bare = res[:ref].split(":", 2)
140
+ target_key = canonical_store_key(slug, store_index)
141
+ node = (node_index[target_key] || {})[bare]
142
+ return nil if node.nil?
143
+
144
+ { target: "#{slug}:#{node[:basename]}", label: node[:label] }
145
+ else # :dead
146
+ nil
147
+ end
148
+ end
149
+
150
+ # Map a ref store slug ("global", "knowdb", "plastic") to a node_index/store_index
151
+ # key ("global", "project:knowdb", "project:plastic"). Mirrors
152
+ # GraphRebuild.canonical_store_key's intent for the node_index keyspace.
153
+ def canonical_store_key(slug, store_index)
154
+ return "global" if slug == "global"
155
+ return slug if (store_index || {}).key?(slug)
156
+
157
+ projected = "project:#{slug}"
158
+ (store_index || {}).key?(projected) ? projected : slug
159
+ end
160
+ end
@@ -0,0 +1,207 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_validator"
5
+
6
+ # LinksSection — pure, minimal, style-preserving rewrite of ONLY the `## Links`
7
+ # section of an intent file's content string (intent 72). Mirrors the discipline of
8
+ # FrontmatterWriter: pure (no file IO, no eval, no global/ENV state), and returns
9
+ # the original content UNCHANGED when nothing changed (idempotency).
10
+ #
11
+ # `## Links` is canonically the LAST sanctioned section
12
+ # (IntentValidator::SANCTIONED_SECTIONS = Intent, Context, Outcome, Insights,
13
+ # Links), and real intents put it last. So a file with an existing `## Links` has
14
+ # its section replaced in place (from the heading to the next top-level `## `
15
+ # heading or EOF), and a file WITHOUT a `## Links` gets one appended at end-of-body,
16
+ # separated by exactly one blank line, preserving the body's trailing-newline shape.
17
+ #
18
+ # FENCE AWARENESS (intent 72 corruption fix): a `## Links` heading INSIDE a fenced
19
+ # code block (``` or ~~~, possibly with an info string like ```markdown) is part of
20
+ # an EXAMPLE, not a real section. All section scanning here IGNORES headings inside
21
+ # fences and only ever targets the REAL `## Links` section (outside any fence). The
22
+ # section end is the next `## ` heading that is ALSO outside a fence, so a replace
23
+ # never consumes or unbalances a code fence. If more than one REAL `## Links`
24
+ # heading exists, #rewrite raises AmbiguousLinks rather than guess.
25
+ #
26
+ # Frontmatter is NEVER touched: the leading `---`...`---` block is preserved
27
+ # byte-for-byte and only the body is rebuilt.
28
+ module LinksSection
29
+ module_function
30
+
31
+ HEADING = "## Links"
32
+
33
+ # A fence delimiter: ``` or ~~~ (any length >= 3), optional leading whitespace,
34
+ # optional info string (e.g. ```markdown). Mirrors CommonMark fenced-code rules
35
+ # closely enough for intent bodies.
36
+ FENCE_RE = /\A\s*(`{3,}|~{3,})/
37
+
38
+ # Raised when a body has more than one REAL `## Links` heading outside any fence;
39
+ # the tool must fail loud rather than guess which one to rewrite.
40
+ class AmbiguousLinks < StandardError
41
+ def initialize(count)
42
+ super("found #{count} real `## Links` headings outside code fences; refusing to guess")
43
+ end
44
+ end
45
+
46
+ # PURE. Replace (or insert) the REAL `## Links` section in `content` with
47
+ # `section_text` (the canonical block from LinksProjection.section, which begins
48
+ # with the `## Links` heading line and ends with a single trailing newline).
49
+ # Returns the new content, or the original when nothing changed. Raises
50
+ # AmbiguousLinks when more than one real `## Links` heading exists.
51
+ def rewrite(content, section_text)
52
+ return content unless content.is_a?(String)
53
+
54
+ fm, body = split_frontmatter(content)
55
+ new_body = rewrite_body(body, section_text)
56
+ updated = "#{fm}#{new_body}"
57
+ updated == content ? content : updated
58
+ end
59
+
60
+ # Split content into [frontmatter_with_delimiters, body]. When there is no
61
+ # frontmatter block, the frontmatter part is "" and the whole content is the
62
+ # body. The frontmatter part is preserved byte-for-byte by the caller.
63
+ def split_frontmatter(content)
64
+ return ["", content] unless content.start_with?("---")
65
+
66
+ parts = content.split("---", 3)
67
+ return ["", content] if parts.length < 3
68
+
69
+ ["---#{parts[1]}---", parts[2]]
70
+ end
71
+
72
+ # Rewrite ONLY the REAL `## Links` section within the body text.
73
+ def rewrite_body(body, section_text)
74
+ bounds = links_bounds(body)
75
+ if bounds
76
+ replace_section(body, section_text, bounds)
77
+ else
78
+ insert_section(body, section_text)
79
+ end
80
+ end
81
+
82
+ # PURE. Locate the REAL `## Links` section (fence-aware). Returns
83
+ # [start_index, end_index] line indices into body.lines, where start_index is the
84
+ # `## Links` heading line and end_index is the index of the next out-of-fence
85
+ # `## ` heading (or lines.length at EOF). Returns nil when there is no real
86
+ # `## Links` heading. Raises AmbiguousLinks when more than one exists.
87
+ def links_bounds(body)
88
+ lines = body.to_s.lines
89
+ starts = real_links_heading_indices(lines)
90
+ return nil if starts.empty?
91
+ raise AmbiguousLinks, starts.length if starts.length > 1
92
+
93
+ start = starts.first
94
+ stop = next_out_of_fence_heading(lines, start + 1)
95
+ [start, stop]
96
+ end
97
+
98
+ # PURE. Indices of every `## Links` heading line that is OUTSIDE any code fence.
99
+ # Accepts a body String or an Array of lines.
100
+ def real_links_heading_indices(body_or_lines)
101
+ lines = body_or_lines.is_a?(Array) ? body_or_lines : body_or_lines.to_s.lines
102
+ indices = []
103
+ in_fence = false
104
+ fence_marker = nil
105
+ lines.each_with_index do |line, i|
106
+ if (m = fence_open_close(line, in_fence, fence_marker))
107
+ in_fence = m[:in_fence]
108
+ fence_marker = m[:marker]
109
+ next
110
+ end
111
+ indices << i if !in_fence && line.rstrip == HEADING
112
+ end
113
+ indices
114
+ end
115
+
116
+ # PURE. Index of the first `## ` heading at or after `from` that is OUTSIDE any
117
+ # code fence. Returns lines.length when none (EOF). Fence state is recomputed
118
+ # from the top so nested example fences after the real heading are respected.
119
+ def next_out_of_fence_heading(lines, from)
120
+ in_fence = false
121
+ fence_marker = nil
122
+ lines.each_with_index do |line, i|
123
+ if (m = fence_open_close(line, in_fence, fence_marker))
124
+ in_fence = m[:in_fence]
125
+ fence_marker = m[:marker]
126
+ next
127
+ end
128
+ return i if i >= from && !in_fence && line.start_with?("## ")
129
+ end
130
+ lines.length
131
+ end
132
+
133
+ # PURE. Given the current fence state, decide whether `line` is a fence delimiter
134
+ # and return the new state, or nil when the line is not a fence delimiter.
135
+ # An opening fence records its marker family (` or ~); a closing fence must use a
136
+ # marker of the SAME family and carry no info string.
137
+ def fence_open_close(line, in_fence, fence_marker)
138
+ m = line.match(FENCE_RE)
139
+ return nil unless m
140
+
141
+ marker = m[1]
142
+ family = marker[0] # "`" or "~"
143
+ if in_fence
144
+ # A closing fence uses the same family, length >= the opener, no info string.
145
+ rest = line.sub(FENCE_RE, "").strip
146
+ if family == fence_marker && rest.empty?
147
+ { in_fence: false, marker: nil }
148
+ else
149
+ # A delimiter of the OTHER family (or an info-string line) inside a fence is
150
+ # literal content, not a fence event.
151
+ nil
152
+ end
153
+ else
154
+ { in_fence: true, marker: family }
155
+ end
156
+ end
157
+
158
+ # True iff the body has a REAL (out-of-fence) `## Links` heading. Used by callers
159
+ # to classify regenerate-vs-add without re-deriving fence state.
160
+ def links_heading?(body)
161
+ !real_links_heading_indices(body.to_s.lines).empty?
162
+ end
163
+
164
+ # Replace the REAL `## Links` section (the [start, stop] line bounds) with
165
+ # `section_text`, preserving everything before the heading and after the section
166
+ # byte-for-byte (including any fenced example that lives BEFORE the real section).
167
+ def replace_section(body, section_text, bounds)
168
+ lines = body.lines
169
+ start, stop = bounds
170
+ before = lines[0...start].join
171
+ tail = (lines[stop..] || [])
172
+
173
+ # `section_text` already ends with exactly one newline. When there is trailing
174
+ # content (another section follows), separate the block from it with one blank
175
+ # line; otherwise the section ends the body.
176
+ block = tail.empty? ? section_text : "#{section_text}\n"
177
+ "#{before}#{block}#{tail.join}"
178
+ end
179
+
180
+ # Append a `## Links` section at end-of-body, after the last existing section,
181
+ # separated by exactly one blank line, preserving the body's trailing newline.
182
+ def insert_section(body, section_text)
183
+ trimmed = body.to_s.sub(/\s+\z/, "")
184
+ if trimmed.empty?
185
+ # An empty body (no sections) just becomes the section.
186
+ section_text
187
+ else
188
+ "#{trimmed}\n\n#{section_text}"
189
+ end
190
+ end
191
+
192
+ # PURE. Extract the REAL `## Links` section text (fence-aware), normalized to the
193
+ # canonical block shape the projection emits: heading line + entry lines + a
194
+ # single trailing newline. Returns "" when there is no real section. Shared by
195
+ # the IO shell's audit and the doctor drift check so all three agree on the
196
+ # location. Raises AmbiguousLinks when more than one real heading exists.
197
+ def extract_section(body)
198
+ bounds = links_bounds(body)
199
+ return "" if bounds.nil?
200
+
201
+ start, stop = bounds
202
+ lines = body.to_s.lines
203
+ section = lines[(start + 1)...stop].join.sub(/\n+\z/, "\n")
204
+ section = "" if section.strip.empty?
205
+ "#{HEADING}\n#{section}"
206
+ end
207
+ end