@zalom/plastic 1.0.0-beta.16 → 1.0.0-beta.18

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 CHANGED
@@ -57,6 +57,24 @@ tags: [plastic, architecture]
57
57
  Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
58
58
  preserved within each group. Sources never appear at the end. No source/chain tags, no
59
59
  sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
60
+ - `## Links` is a DERIVED view, not a place to author links (Convention over Configuration).
61
+ It equals the projection of `sources` (first) then `chain`. Never hand-write or hand-edit a
62
+ `## Links` line, and never auto-delete one. The edge lives in the frontmatter graph; the
63
+ section is regenerated from it (doctor `graph_links_projection` enforces this identity). To
64
+ add a link, add the frontmatter edge, then reproject.
65
+ - Links are decided by CONTEXT INFLUENCE, not by shared files, shared symbols, or a topic
66
+ similarity score. The question is whether one intent's context actually informed another.
67
+ Three tiers:
68
+ - **sources:** the foundational context that shaped this intent's creation (a split, an idea
69
+ born during development, a merge). Earns an edge.
70
+ - **chain:** the context that materially helps DELIVER this intent. This is a HIGH bar: only
71
+ the genuinely delivery-moving intents, not everything in the same area. Earns an edge,
72
+ reflected in `## Links`.
73
+ - **tags:** a loose theme grouping for search. NOT a link. A shared tag is a door INTO the
74
+ store (filtered discovery), not a pathway BETWEEN two notes.
75
+ Judging influence is an agent's call, made by reading the candidate's Intent and Context. A
76
+ script cannot grade it, so `scripts/link-suggest` only gathers candidates with that evidence,
77
+ records a confirmed edge with a rating and reason, and flags drift.
60
78
  - IDs use Luhmann's alternating convention: `1` → `1a` → `1a1` → `1a1a`
61
79
  - Multiple branches increment: `1a`, `1b`, `1c`
62
80
 
@@ -238,20 +256,29 @@ hygiene after each intent. Advisory self-check, not hard-verifiable.
238
256
  ## Retrieval Gate
239
257
 
240
258
  A single capability-aware PreToolUse gate enforces retrieval-first routing on the agent's own
241
- Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). Detection is binary:
242
- present means enforce, absent or down means off, with no warning and no advisory tier.
243
-
244
- - Store markdown (under a Plastic store) routes to QMD when QMD is present and the index is
245
- fresh: the raw grep/find/Read is blocked and you use `qmd search`/`qmd query` (or
246
- `scripts/qmd-sync search`) instead. When QMD is present but stale, the read is allowed this
247
- turn and a background reindex is fired so the next turn enforces against a fresh index;
248
- reindex is never synchronous. When QMD is absent or down, raw reads are allowed.
249
- - Serena-supported code and data files route to Serena symbolic tools when Serena is present;
250
- absent means allowed.
251
- - Images, binaries, and everything else are allowed.
252
- - Bypass: append a trailing `# qmd-ok` shell comment to a Bash command for the rare case where
253
- QMD is healthy but you genuinely need the raw read. A quoted or echoed occurrence does not
254
- bypass. Bypasses are logged.
259
+ Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). The gate is
260
+ OPERATION-based: it separates searching from reading, and it never stands between you and
261
+ reading something you have already located.
262
+
263
+ - Only CONTENT SEARCH over a Plastic store is gated. The Grep tool and bash `grep`/`rg`/`ag`
264
+ whose target is at or under a store route to QMD when QMD is present and the index is fresh:
265
+ the raw scan is blocked and you use `qmd search`/`qmd query` (or `scripts/qmd-sync search`)
266
+ instead. When QMD is present but stale, the search is allowed this turn and a background
267
+ reindex is fired so the next turn enforces against a fresh index; reindex is never
268
+ synchronous. When QMD is absent, the search is allowed.
269
+ - Reading a known target (the Read tool, bash `cat`/`head`/`tail`) and structural discovery
270
+ (the Glob tool, bash `find`/`ls`) are always allowed, including over the store. QMD cannot
271
+ list directories or hand back one specific file, so these are never gated.
272
+ - Code is never hard-gated here. Symbolic code navigation via Serena is a soft prompt mandate
273
+ (the UserPromptSubmit power-tools hook), not a block: content grep over code is allowed,
274
+ because Serena navigates symbols and cannot grep arbitrary strings.
275
+ - QMD failure model. Absent or stale degrades to allow (stale also fires the background
276
+ reindex). A broken QMD, where the freshness probe errors or times out, also fails open, and
277
+ the hook emits a one-line warning so a degraded QMD is visible rather than silent.
278
+ - Bypass: append a trailing `# qmd-ok` shell comment to a Bash command when you attempted
279
+ discovery and it did not serve you (no hits, or results that do not answer your need by your
280
+ reading of the snippets, not their score). A quoted or echoed occurrence does not bypass.
281
+ Bypasses are logged. The gate enforces that discovery was attempted, never that it succeeded.
255
282
  - Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
256
283
  and is out of scope by design.
257
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.16",
3
+ "version": "1.0.0-beta.18",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,14 +2,19 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # PreToolUse retrieval gate (intent 84, Lever 2). Reads the tool call (JSON on
6
- # stdin: tool_name + tool_input), computes capabilities (QMD detect+freshness,
7
- # Serena detect), delegates the decision to RetrievalGate, and enforces:
5
+ # PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a).
6
+ # Reads the tool call (JSON on stdin: tool_name + tool_input), computes
7
+ # capabilities (QMD detect + freshness), delegates the decision to RetrievalGate,
8
+ # and enforces:
8
9
  # ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
9
10
  # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
10
11
  # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
11
12
  # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
12
13
  #
14
+ # Only CONTENT SEARCH over store markdown is gated; reads and structural ops are
15
+ # allowed. Code navigation is a soft prompt mandate (UserPromptSubmit power-tools),
16
+ # not enforced here, so this hook no longer detects Serena.
17
+ #
13
18
  # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
14
19
  # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
15
20
  #
@@ -19,15 +24,14 @@ require "json"
19
24
  require "timeout"
20
25
  require_relative "lib/retrieval_gate"
21
26
  require_relative "lib/qmd_sync"
22
- require_relative "lib/power_tools"
23
27
 
24
28
  module RetrievalGateHook
25
29
  module_function
26
30
 
27
31
  # Pure-ish core: capabilities and reindex are injected so this is unit-testable
28
- # with no real qmd/serena. Returns [exit_code, stderr_string].
32
+ # with no real qmd. Returns [exit_code, stderr_string].
29
33
  # stdin: raw PreToolUse JSON
30
- # capabilities: { qmd:, qmd_fresh:, serena: }
34
+ # capabilities: { qmd:, qmd_fresh: }
31
35
  # reindex: callable fired on the STALE path
32
36
  def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
33
37
  payload = parse(stdin)
@@ -62,23 +66,33 @@ module RetrievalGateHook
62
66
  nil
63
67
  end
64
68
 
65
- # Detect real capabilities for the live executable. A slow `qmd status` cannot
66
- # stall a tool call: a Timeout around the freshness probe degrades to "absent
67
- # for this turn" (allow, no reindex), staying fail-open and non-blocking.
68
- def detect_capabilities(cwd:)
69
- qmd = QmdSync.detect
69
+ # Detect real capabilities for the live executable. Probes are injected so the
70
+ # three-tier QMD failure model is unit-testable:
71
+ # - absent : QMD not on PATH -> allow, no warn.
72
+ # - broken : QMD present but freshness probe -> tier-b: WARN once, then treat
73
+ # raises/times out as absent this turn (allow).
74
+ # - fresh : probe returns truthy -> gate is live.
75
+ # A slow `qmd status` cannot stall a tool call: a 2s Timeout bounds the probe.
76
+ def detect_capabilities(cwd:,
77
+ detect: -> { QmdSync.detect },
78
+ fresh: -> { QmdSync.fresh? },
79
+ warn: ->(m) { $stderr.puts(m) })
80
+ qmd = detect.call
70
81
  qmd_fresh = false
71
82
  if qmd
72
- qmd_fresh = begin
73
- Timeout.timeout(2) { QmdSync.fresh? }
83
+ begin
84
+ qmd_fresh = Timeout.timeout(2) { fresh.call }
74
85
  rescue StandardError
75
- # Probe stalled/failed: treat as absent this turn (allow, no reindex).
86
+ # Tier-b: QMD is present but its freshness probe broke/stalled. Distinct
87
+ # from QMD being absent — warn so a degraded QMD is visible, then fail open
88
+ # (allow this turn, no reindex).
89
+ warn.call("PLASTIC GATE — QMD is present but its freshness probe failed; " \
90
+ "allowing this turn without routing search to QMD (check qmd).")
76
91
  qmd = false
77
- false
92
+ qmd_fresh = false
78
93
  end
79
94
  end
80
- serena = PowerTools.serena?(cwd: cwd)
81
- { qmd: qmd, qmd_fresh: qmd_fresh, serena: serena }
95
+ { qmd: qmd, qmd_fresh: qmd_fresh }
82
96
  end
83
97
 
84
98
  # Best-effort reindex callable for the STALE path. Resolves the collection from
@@ -223,7 +223,9 @@ class InstallerCore
223
223
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
224
224
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
225
225
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
226
+ "scripts/lib/link_suggestions.rb" => "scripts/lib/link_suggestions.rb",
226
227
  "scripts/project-links" => "scripts/project-links",
228
+ "scripts/link-suggest" => "scripts/link-suggest",
227
229
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
228
230
  "scripts/validate-intent" => "scripts/validate-intent",
229
231
  "scripts/new-intent" => "scripts/new-intent",
@@ -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
@@ -3,62 +3,57 @@
3
3
 
4
4
  require_relative "bridge"
5
5
 
6
- # RetrievalGate — the single, pure decision for Lever 2 of intent 84.
6
+ # RetrievalGate — the single, pure decision for Lever 2 of intent 84, redesigned
7
+ # operation-based in intent 89a.
7
8
  #
8
9
  # Given an agent tool call (Bash/Read/Grep/Glob) and injected capability signals,
9
- # it decides whether to BLOCK the call (returning a redirect-to-QMD/Serena reason
10
- # String) or ALLOW it (returning nil). All capability and freshness signals are
11
- # injected by the caller (the hook); this module shells out to nothing, reads no
12
- # globals, and runs no binaries. Mirrors bridge.rb's decision-fn convention
13
- # (reason String to block, nil to allow).
10
+ # it decides whether to BLOCK the call (returning a redirect-to-QMD reason String)
11
+ # or ALLOW it (returning nil). All capability/freshness signals are injected by the
12
+ # caller (the hook); this module shells out to nothing, reads no globals, and runs
13
+ # no binaries. Mirrors bridge.rb's decision-fn convention (reason String to block,
14
+ # nil to allow).
14
15
  #
15
- # Classification (per target path):
16
- # - store `*.md` (under <plastic_home>/store or .../projects/<slug>/store) -> QMD
17
- # - Serena-supported code/data file (NOT a store markdown) -> SERENA
18
- # - images / binary / other -> ALLOWED
16
+ # Operation-based policy (intent 89, ## Redesign):
17
+ # - The gate distinguishes DISCOVERY (content search) from READING a known target.
18
+ # - Only CONTENT SEARCH over store markdown is hard-gated -> QMD.
19
+ # - Reading a known target (Read, cat/head/tail) and structural discovery (Glob,
20
+ # find, ls) are ALWAYS allowed, including over the store.
21
+ # - Code navigation is a soft prompt MANDATE (PowerTools / UserPromptSubmit), not a
22
+ # hard gate here. Content grep over code is allowed (Serena cannot grep strings).
19
23
  #
20
- # Capability enforcement is BINARY (no advisory tier):
21
- # - QMD class: detected+fresh -> BLOCK; detected+stale -> fire reindex, ALLOW
22
- # this turn; absent/down -> ALLOW (no warning).
23
- # - SERENA class: detected -> BLOCK; absent -> ALLOW.
24
+ # Content-search vectors (the only ones that can be gated):
25
+ # - the Grep tool (its `path` search root)
26
+ # - bash `grep`/`rg`/`ag` (their path args; the first bareword is the PATTERN)
24
27
  #
25
- # Bypass: a TRAILING `# qmd-ok` shell comment on a Bash command (not a substring;
26
- # a quoted/echoed occurrence does not bypass).
28
+ # QMD enforcement is BINARY (no advisory tier):
29
+ # - store-md content search: QMD detected+fresh -> BLOCK; detected+stale -> fire
30
+ # reindex, ALLOW this turn; absent/broken -> ALLOW (the hook warns on broken).
27
31
  #
28
- # Scope: only the agent's own tool calls. Ruby `File.read` inside scripts is
29
- # invisible to a PreToolUse hook and is explicitly out of scope (no exemptions).
32
+ # Bypass: a TRAILING `# qmd-ok` shell comment on a Bash command (not a substring; a
33
+ # quoted/echoed occurrence does not bypass). It is the auditable seam for "I tried
34
+ # discovery and it did not serve me" (empty, low, or wrongly-scored results).
35
+ #
36
+ # Scope: only the agent's own tool calls. Ruby `File.read` inside scripts is invisible
37
+ # to a PreToolUse hook and is explicitly out of scope (no exemptions).
30
38
  module RetrievalGate
31
39
  module_function
32
40
 
33
- # Serena LSP covers many languages incl. JSON/YAML/TOML/Markdown/Ruby. Keep a
34
- # small, conservative allowlist of code/data extensions. Markdown is listed but
35
- # store markdown is reclassified to QMD before Serena ever sees it.
36
- SERENA_EXTENSIONS = %w[
37
- rb js jsx ts tsx mjs cjs py go rs java kt scala c h cpp hpp cc
38
- cs php rb swift sh bash zsh lua ex exs erl clj sql
39
- json yaml yml toml
40
- ].freeze
41
-
42
- # Image / binary extensions that are always allowed (plain read is fine).
43
- BINARY_EXTENSIONS = %w[
44
- png jpg jpeg gif webp svg ico bmp tiff pdf
45
- zip gz tar tgz bz2 xz 7z
46
- mp3 mp4 mov avi wav flac ogg
47
- woff woff2 ttf otf eot
48
- bin exe dll so dylib o a class jar wasm
49
- ].freeze
50
-
51
41
  # A `# qmd-ok` token that is a real TRAILING shell comment, after stripping a
52
42
  # trailing newline. The token must be preceded by whitespace (or start the
53
- # command) and run to end-of-string. `echo "# qmd-ok"` does NOT match: the
54
- # token there is followed by a closing quote, not end-of-string.
43
+ # command) and run to end-of-string. `echo "# qmd-ok"` does NOT match: the token
44
+ # there is followed by a closing quote, not end-of-string.
55
45
  BYPASS_RE = /(?:\A|\s)#\s*qmd-ok\s*\z/.freeze
56
46
 
47
+ # Bash utilities that perform CONTENT SEARCH (scan file CONTENT for a pattern).
48
+ # These are the only bash read-vectors that can be gated; readers (cat/head/tail)
49
+ # and structural tools (find/ls) are never gated.
50
+ CONTENT_SEARCH_UTILS = %w[grep rg ag].freeze
51
+
57
52
  # Decide. Returns nil to ALLOW, or a reason String to BLOCK.
58
- # capabilities: { qmd:, qmd_fresh:, serena: } (booleans).
53
+ # capabilities: { qmd:, qmd_fresh: } (booleans).
59
54
  # reindex: no-arg callable fired once when a QMD-class target is STALE.
60
- # When bypassed, returns nil and (if given) yields :bypass to the optional
61
- # block so the caller can log it.
55
+ # When bypassed, returns nil and (if given) yields :bypass to the optional block
56
+ # so the caller can log it.
62
57
  def decision(tool_name:, tool_input:, plastic_home:, cwd:,
63
58
  capabilities:, reindex: -> {})
64
59
  targets = extract_targets(tool_name, tool_input, cwd: cwd)
@@ -71,17 +66,14 @@ module RetrievalGate
71
66
 
72
67
  stale_seen = false
73
68
  targets.each do |path|
74
- case classify(path, plastic_home: plastic_home)
75
- when :qmd
76
- if capabilities[:qmd] && capabilities[:qmd_fresh]
77
- return qmd_reason(path)
78
- elsif capabilities[:qmd] # present but stale
79
- stale_seen = true
80
- end
81
- # absent/down -> allow this target
82
- when :serena
83
- return serena_reason(path) if capabilities[:serena]
69
+ next unless classify(path, plastic_home: plastic_home) == :qmd
70
+
71
+ if capabilities[:qmd] && capabilities[:qmd_fresh]
72
+ return qmd_reason(path)
73
+ elsif capabilities[:qmd] # present but stale
74
+ stale_seen = true
84
75
  end
76
+ # absent/broken -> allow this target
85
77
  end
86
78
 
87
79
  reindex.call if stale_seen
@@ -90,27 +82,21 @@ module RetrievalGate
90
82
 
91
83
  # --- classification ---
92
84
 
85
+ # Operation-based: the store tree is the only gated class (content search whose
86
+ # target is at/under a store routes to QMD). Everything else is allowed.
93
87
  def classify(path, plastic_home:)
94
88
  return :allow if path.nil? || path.empty?
95
- ext = extension(path)
96
-
97
- if store_markdown?(path, plastic_home: plastic_home)
98
- return :qmd
99
- end
100
- return :allow if BINARY_EXTENSIONS.include?(ext)
101
- return :serena if SERENA_EXTENSIONS.include?(ext)
102
-
103
- :allow
89
+ store_path?(path, plastic_home: plastic_home) ? :qmd : :allow
104
90
  end
105
91
 
106
- # A markdown file under the global store or a project store. QMD owns store
107
- # markdown even though Serena could also read markdown (QMD wins for the store).
108
- def store_markdown?(path, plastic_home:)
109
- return false unless %w[md markdown].include?(extension(path))
92
+ # A path AT or UNDER the global store or a project store. We gate the whole store
93
+ # tree (not just `*.md`) because a content search root is usually a directory:
94
+ # grepping the store scans its markdown, which is exactly what QMD should serve.
95
+ def store_path?(path, plastic_home:)
110
96
  abs = absolutize(path)
111
97
  home = File.expand_path(plastic_home)
112
98
  global = File.join(home, "store")
113
- return true if abs.start_with?("#{global}/")
99
+ return true if abs == global || abs.start_with?("#{global}/")
114
100
 
115
101
  projects = File.join(home, "projects")
116
102
  return false unless abs.start_with?("#{projects}/")
@@ -118,19 +104,14 @@ module RetrievalGate
118
104
  tail.length >= 2 && tail[1] == "store"
119
105
  end
120
106
 
121
- def extension(path)
122
- File.extname(path.to_s).sub(/\A\./, "").downcase
123
- end
124
-
125
107
  def absolutize(path)
126
108
  File.absolute_path?(path) ? path : File.expand_path(path)
127
109
  end
128
110
 
129
111
  # --- bypass ---
130
112
 
131
- # Only Bash commands carry a trailing `# qmd-ok` comment. The token must be a
132
- # real trailing comment (BYPASS_RE), so a quoted/echoed occurrence does not
133
- # bypass.
113
+ # Only Bash commands carry a trailing `# qmd-ok` comment. The token must be a real
114
+ # trailing comment (BYPASS_RE), so a quoted/echoed occurrence does not bypass.
134
115
  def bypass?(tool_name, tool_input)
135
116
  return false unless tool_name.to_s == "Bash"
136
117
  cmd = tool_input.is_a?(Hash) ? tool_input["command"].to_s : ""
@@ -139,40 +120,36 @@ module RetrievalGate
139
120
 
140
121
  # --- target extraction ---
141
122
 
142
- # Paths the call reads/scans. Conservative: missing an exotic form is fine;
143
- # never flag /dev/null or pure pipes. Read vectors only (this is a READ gate),
144
- # not the write vectors bridge.rb already covers.
123
+ # Paths a CONTENT-SEARCH operation scans. Reads (Read, cat/head/tail) and
124
+ # structural discovery (Glob, find, ls) are NOT content search -> no targets ->
125
+ # always allowed. Only the Grep tool and bash grep/rg/ag can be gated. Read
126
+ # vectors only (this is a READ gate); write vectors are bridge.rb's job.
145
127
  def extract_targets(tool_name, tool_input, cwd:)
146
128
  input = tool_input.is_a?(Hash) ? tool_input : {}
147
129
  case tool_name.to_s
148
- when "Read"
149
- [input["file_path"]].compact.reject(&:empty?)
150
- when "Glob"
151
- [input["path"], input["pattern"]].compact.reject { |s| s.to_s.empty? }
152
130
  when "Grep"
153
131
  # The search root is the target; the query text is not a path.
154
132
  [input["path"]].compact.reject { |s| s.to_s.empty? }
155
133
  when "Bash"
156
- bash_read_targets(input["command"].to_s)
134
+ bash_search_targets(input["command"].to_s)
157
135
  else
136
+ # Read, Glob, and every other tool: read / structural op -> never gated.
158
137
  []
159
138
  end
160
139
  end
161
140
 
162
- # READ utilities that take file/dir path arguments. Conservative parse: split
163
- # on shell separators, identify the utility, collect its non-flag path args.
164
- READ_UTILS = %w[grep rg ag find cat head tail less more bat ls wc nl sort uniq].freeze
165
-
166
- def bash_read_targets(command)
141
+ # CONTENT-SEARCH path args across a compound command. Conservative: missing an
142
+ # exotic form is fine; never flag /dev/null or pure pipes.
143
+ def bash_search_targets(command)
167
144
  return [] unless command.is_a?(String) && !command.empty?
168
145
  targets = []
169
146
  command.split(/[;\n]|&&|\|\||\|/).each do |segment|
170
- targets.concat(segment_read_targets(segment))
147
+ targets.concat(segment_search_targets(segment))
171
148
  end
172
149
  targets.reject { |t| t.nil? || t.empty? || dev_path?(t) }.uniq
173
150
  end
174
151
 
175
- def segment_read_targets(segment)
152
+ def segment_search_targets(segment)
176
153
  tokens = tokenize(segment)
177
154
  return [] if tokens.empty?
178
155
 
@@ -180,21 +157,20 @@ module RetrievalGate
180
157
  idx = 0
181
158
  idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /\A-/
182
159
  util = File.basename(tokens[idx].to_s)
183
- return [] unless READ_UTILS.include?(util)
160
+ return [] unless CONTENT_SEARCH_UTILS.include?(util)
184
161
 
185
162
  args = tokens[(idx + 1)..] || []
186
- path_args_for(util, args)
163
+ path_args_for(args)
187
164
  end
188
165
 
189
- # Collect path-shaped arguments for a read utility. Flags and flag-values are
190
- # skipped; for grep/rg the first non-flag bareword is the PATTERN, not a path.
191
- def path_args_for(util, args)
192
- skip_pattern = %w[grep rg ag].include?(util)
166
+ # Collect path-shaped arguments for a content-search util. Flags are skipped; the
167
+ # first non-flag bareword is the PATTERN, not a path.
168
+ def path_args_for(args)
193
169
  paths = []
194
170
  pattern_consumed = false
195
171
  args.each do |a|
196
172
  next if a.start_with?("-")
197
- if skip_pattern && !pattern_consumed
173
+ unless pattern_consumed
198
174
  pattern_consumed = true
199
175
  next
200
176
  end
@@ -225,14 +201,11 @@ module RetrievalGate
225
201
  # --- reasons ---
226
202
 
227
203
  def qmd_reason(path)
228
- "retrieval gate: search the store via QMD, not raw grep/Read. " \
229
- "Use `qmd search`/`qmd query` over the `plastic-*` collections (or " \
230
- "`scripts/qmd-sync search`) instead of reading #{path}. " \
231
- "If you genuinely need the raw read, append a trailing `# qmd-ok` to a Bash command."
232
- end
233
-
234
- def serena_reason(path)
235
- "retrieval gate: navigate code via Serena's symbolic tools (find_symbol / " \
236
- "get_symbols_overview / find_referencing_symbols), not raw grep/Read of #{path}."
204
+ "retrieval gate: search the store via QMD, not a raw content scan. Reading a " \
205
+ "known file and listing/globbing the store are fine; only CONTENT SEARCH over " \
206
+ "store markdown routes through QMD. Use `qmd search`/`qmd query` over the " \
207
+ "`plastic-*` collections (or `scripts/qmd-sync search`) instead of scanning " \
208
+ "#{path}. If QMD's results do not answer your need (your reading of the " \
209
+ "snippets, not their score), append a trailing `# qmd-ok` to a Bash command."
237
210
  end
238
211
  end
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # link-suggest - support links decided by CONTEXT INFLUENCE (intent 91, D7). Links are
6
+ # NOT graded by a script: whether one intent's context influenced another is a
7
+ # judgement an agent makes by reading the candidate's Intent + Context. This tool
8
+ # gathers candidates with that evidence, records a CONFIRMED edge plus its rating and
9
+ # reason in `link-decisions.md`, and flags drift. It never writes a `## Links` line and
10
+ # never deletes.
11
+ #
12
+ # `## Links` is a DERIVED view of `sources`/`chain` (Convention over Configuration).
13
+ # Tiers, by context influence:
14
+ # sources - the foundational context that shaped this intent's creation.
15
+ # chain - the context that materially helps DELIVER this intent. HIGH bar.
16
+ # tags - loose theme grouping for search. Not a link.
17
+ #
18
+ # Usage:
19
+ # link-suggest <subject_id> [--store-dir PATH] [--plastic-home PATH]
20
+ # link-suggest <subject_id> --record <target_id> --edge <sources|chain>
21
+ # --rating <high|medium|low> --reason "..." --confirm
22
+ # link-suggest --help
23
+ #
24
+ # Default (no --record) prints candidates WITH their Intent+Context plus any drift, and
25
+ # writes nothing. --record without --confirm prints what would happen and writes
26
+ # nothing. --edge is explicit and required (sources is decided by origin, never
27
+ # inferred); --rating is required when --edge is chain.
28
+
29
+ require_relative "lib/link_suggestions"
30
+
31
+ module LinkSuggestCLI
32
+ module_function
33
+
34
+ DEFAULT_HOME = File.join(Dir.home, ".plastic")
35
+
36
+ USAGE = <<~TXT
37
+ Usage:
38
+ link-suggest <subject_id> [--store-dir PATH] [--plastic-home PATH]
39
+ link-suggest <subject_id> --record <target_id> --edge <sources|chain> \\
40
+ --rating <high|medium|low> --reason "..." --confirm
41
+ link-suggest --help
42
+
43
+ Links are decided by context influence, judged by an agent reading each
44
+ candidate's Intent + Context. This tool only gathers candidates with that
45
+ evidence, records a confirmed edge plus its rating/reason in link-decisions.md,
46
+ and flags drift. It never writes a ## Links line and never deletes.
47
+
48
+ --edge is explicit and required (sources is decided by origin, never inferred).
49
+ --rating is required when --edge is chain. Without --confirm, nothing is written.
50
+ TXT
51
+
52
+ def parse(argv)
53
+ opts = { home: DEFAULT_HOME, store_dir: nil, subject: nil, help: false,
54
+ record: nil, edge: nil, rating: nil, reason: nil, confirm: false }
55
+ i = 0
56
+ while i < argv.length
57
+ case argv[i]
58
+ when "--help", "-h" then opts[:help] = true; i += 1
59
+ when "--plastic-home" then opts[:home] = argv[i + 1]; i += 2
60
+ when "--store-dir" then opts[:store_dir] = argv[i + 1]; i += 2
61
+ when "--record" then opts[:record] = argv[i + 1]; i += 2
62
+ when "--edge" then opts[:edge] = (argv[i + 1] || "").to_sym; i += 2
63
+ when "--rating" then opts[:rating] = argv[i + 1]; i += 2
64
+ when "--reason" then opts[:reason] = argv[i + 1]; i += 2
65
+ when "--confirm" then opts[:confirm] = true; i += 1
66
+ else
67
+ opts[:subject] ||= argv[i]
68
+ i += 1
69
+ end
70
+ end
71
+ opts[:store_dir] ||= File.join(opts[:home], "store")
72
+ opts
73
+ end
74
+
75
+ # Print the discovery candidates with their Intent + Context evidence, plus drift.
76
+ def report(tool, subject, out: $stdout)
77
+ nodes = tool.load_nodes
78
+ unless nodes.key?(subject)
79
+ out.puts "link-suggest: no intent #{subject.inspect} in #{tool.store_dir}"
80
+ return 1
81
+ end
82
+
83
+ cands = tool.gather(subject, nodes: nodes)
84
+ out.puts "Candidates for #{subject} (discovery only; judge influence by reading context):"
85
+ if cands.empty?
86
+ out.puts " (none)"
87
+ else
88
+ cands.each { |c| print_candidate(c, out) }
89
+ end
90
+
91
+ flaws = tool.drift(subject, nodes: nodes)
92
+ out.puts "Drift for #{subject}:"
93
+ if flaws.empty?
94
+ out.puts " (none)"
95
+ else
96
+ flaws.each { |f| out.puts " [drift] #{f.detail}" }
97
+ end
98
+ 0
99
+ end
100
+
101
+ def print_candidate(cand, out)
102
+ out.puts " - #{cand.id} #{cand.label}"
103
+ out.puts " Intent: #{excerpt(cand.intent)}" unless cand.intent.empty?
104
+ out.puts " Context: #{excerpt(cand.context)}" unless cand.context.empty?
105
+ end
106
+
107
+ # A one-paragraph excerpt of a section, for scannable evidence.
108
+ def excerpt(text, limit: 280)
109
+ flat = text.to_s.gsub(/\s+/, " ").strip
110
+ flat.length > limit ? "#{flat[0, limit]}..." : flat
111
+ end
112
+
113
+ def do_record(tool, opts, out: $stdout)
114
+ if opts[:edge].nil? || !%i[sources chain].include?(opts[:edge])
115
+ out.puts "link-suggest: --record requires --edge sources|chain (explicit, never inferred)."
116
+ return 1
117
+ end
118
+ if opts[:edge] == :chain && (opts[:rating].nil? || !LinkSuggestions::RATINGS.include?(opts[:rating]))
119
+ out.puts "link-suggest: --rating high|medium|low is required for a chain edge."
120
+ return 1
121
+ end
122
+
123
+ unless opts[:confirm]
124
+ out.puts "Would record #{opts[:edge]} edge #{opts[:subject]} -> #{opts[:record]} " \
125
+ "(rating #{opts[:rating] || "-"}, reason: #{opts[:reason] || "-"}). " \
126
+ "Re-run with --confirm to write. Nothing written."
127
+ return 0
128
+ end
129
+
130
+ wrote = tool.record_edge(opts[:subject], opts[:record],
131
+ edge: opts[:edge], rating: opts[:rating],
132
+ reason: opts[:reason], confirm: true)
133
+ if wrote
134
+ out.puts "Recorded #{opts[:edge]} edge #{opts[:subject]} -> #{opts[:record]} " \
135
+ "and appended to link-decisions.md. Reproject with scripts/project-links."
136
+ return 0
137
+ end
138
+ out.puts "No edge written (edge may already exist, or intent missing)."
139
+ 0
140
+ end
141
+
142
+ def run(argv, out: $stdout)
143
+ opts = parse(argv)
144
+ if opts[:help]
145
+ out.puts USAGE
146
+ return 0
147
+ end
148
+ unless opts[:subject]
149
+ out.puts USAGE
150
+ return 1
151
+ end
152
+
153
+ tool = LinkSuggestions.new(store_dir: opts[:store_dir], finder: build_finder(opts[:store_dir]))
154
+
155
+ return do_record(tool, opts, out: out) if opts[:record]
156
+
157
+ report(tool, opts[:subject], out: out)
158
+ end
159
+
160
+ # The real candidate-finder, wired here (not in the lib): use QMD when available,
161
+ # else fall back to the lib's cheap family/tag/adjacent net. Discovery only.
162
+ def build_finder(store_dir)
163
+ qmd = qmd_finder(store_dir)
164
+ qmd || LinkSuggestions::FamilyTagFinder.new
165
+ end
166
+
167
+ # A QMD-backed finder when the `qmd` (or qmd-sync) CLI is on PATH. It seeds discovery
168
+ # from the subject's intent line, unions the hits with the cheap fallback net, and
169
+ # returns ids present in the store. Any failure falls back silently. Discovery only,
170
+ # never a grade.
171
+ def qmd_finder(_store_dir)
172
+ return nil unless qmd_available?
173
+
174
+ fallback = LinkSuggestions::FamilyTagFinder.new
175
+ lambda do |subject_id, nodes|
176
+ base = fallback.call(subject_id, nodes)
177
+ subject = nodes[subject_id]
178
+ return base unless subject
179
+
180
+ hits = qmd_search_ids(subject[:label], nodes)
181
+ (base + hits).uniq.reject { |id| id == subject_id }
182
+ end
183
+ end
184
+
185
+ def qmd_available?
186
+ %w[qmd qmd-sync].any? { |c| system("command -v #{c} >/dev/null 2>&1") }
187
+ rescue StandardError
188
+ false
189
+ end
190
+
191
+ # Best-effort: ask qmd-sync for related text and map any id-shaped tokens back to
192
+ # store ids. Never raises; returns [] on any trouble.
193
+ def qmd_search_ids(query, nodes)
194
+ return [] if query.to_s.strip.empty?
195
+
196
+ out = `ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
197
+ return [] if out.nil? || out.empty?
198
+
199
+ out.scan(/\b([0-9]+[a-z0-9]*)\b/).flatten.uniq.select { |id| nodes.key?(id) }
200
+ rescue StandardError
201
+ []
202
+ end
203
+
204
+ def shell_quote(str)
205
+ "'#{str.to_s.gsub("'", "'\\\\''")}'"
206
+ end
207
+ end
208
+
209
+ if $PROGRAM_NAME == __FILE__
210
+ exit LinkSuggestCLI.run(ARGV)
211
+ end
@@ -62,12 +62,18 @@ Having a "parent" in mind does NOT automatically mean branch. Choose by meaning:
62
62
  direct-ascendant provenance (D1).
63
63
  - **Merely related to / inspired by another intent** (it did NOT come out of that
64
64
  intent's lifecycle): carry NO `--sources`. Record the relation on the PREDECESSOR's
65
- `chain` instead, and mirror it as a `[[id]]` wikilink in `## Links` (the
66
- related-but-not-spawned rule).
65
+ `chain` instead (the related-but-not-spawned rule); the `## Links` section follows
66
+ from that edge, you do not write it by hand.
67
67
  - **Rule of thumb:** if the intent could exist without its parent, make it a root; only set
68
68
  `--sources` when it was genuinely created from / emerged from that intent's lifecycle.
69
69
  Topic similarity alone is not a `sources` edge.
70
70
 
71
+ `## Links` is a DERIVED view of `sources`/`chain`. Never hand-write a `## Links` line; add the
72
+ frontmatter edge and reproject. Links are decided by context influence (a `chain` edge has a high
73
+ bar: the candidate's context must materially help deliver this intent), not by shared files or a
74
+ similarity score. To gather candidates with their context and record an edge, use the
75
+ `plastic-linking-intents` skill and `scripts/link-suggest`.
76
+
71
77
  ### 3. Determine Intent Properties
72
78
 
73
79
  Ask or infer from context:
@@ -6,34 +6,70 @@ description: Use when creating connections between intents, the user says "link"
6
6
  # Linking Intents
7
7
 
8
8
  ## When to Use
9
- - During intent creation (automatic ask about related intents)
9
+ - During intent creation (automatic: ask about related intents)
10
10
  - User says "link", "connect", "relates to"
11
11
  - Agent discovers a relationship between intents during work
12
12
 
13
- ## Connection Types (Ranked by Strength)
13
+ ## Discovery and ranking are separate
14
14
 
15
- ### 1. Direct Links (Strongest)
16
- Explicit wikilinks in the `## Links` section. Bidirectional — add to both intents.
15
+ Two distinct steps, do not conflate them:
17
16
 
18
- ```markdown
19
- ## Links
20
- - [[1a]] research this plan is based on
21
- ```
17
+ 1. **Discovery** (finding candidate related intents) may use any tool: grep, find, ripgrep,
18
+ or QMD/Serena when present (QMD-first per the project rule). Discovery casts a wide net.
19
+ 2. **Ranking** the candidates is a CONTEXT-INFLUENCE judgement: read each candidate's `## Intent`
20
+ and `## Context` and ask whether that context actually informed this intent. Ranking is NOT a
21
+ structural metric (no shared-file or shared-symbol grading: on intent 90, matching whole files
22
+ flagged 35 intents because ~20 touch `bridge.rb`). It is NOT a similarity score either (QMD
23
+ relevance measures topic proximity, not influence). A script cannot make this call; an agent
24
+ does.
25
+
26
+ ## The three tiers (by context influence)
27
+
28
+ - **sources:** the foundational context that shaped this intent's CREATION (a split, an idea born
29
+ during development, a merge). Earns an edge. Decided by origin, never inferred.
30
+ - **chain:** the context that materially helps DELIVER this intent. HIGH bar: only the genuinely
31
+ delivery-moving intents, not everything in the same area. Earns an edge, reflected in `## Links`.
32
+ Worked example (intent 90): 79 created it so 79 is a source; 80 deferred the exact fix 90 makes,
33
+ so its context directly helps delivery and 80 is chain; 49/66/73 are same-area background, so
34
+ they get a shared tag and no link.
35
+ - **tags:** loose theme grouping for search. NOT a link.
36
+
37
+ **Timing.** The influence judgement happens at What/Why (and during upkeep), guided by this rule.
38
+ It does not wait for code to exist; it is reasoning over the candidate's context, not over a diff.
39
+
40
+ **Record the call.** For every edge an agent adds, store a rating (high / medium / low) plus a
41
+ one-line reason in `link-decisions.md` in the intent dir. Keep it out of frontmatter (graph only)
42
+ and out of the `## Links` label (which is projected), so the audit trail never breaks the
43
+ projection identity.
44
+
45
+ ## `## Links` is derived (never author it by hand)
46
+
47
+ `## Links` is a DERIVED view of `sources` then `chain`, not a place to write links. Never
48
+ hand-write a `## Links` line, and never auto-delete one. To add a link, add the frontmatter
49
+ edge (below), then let the projection regenerate the section (`scripts/project-links`).
50
+
51
+ Run `scripts/link-suggest <id>` to gather candidate intents WITH each one's Intent and Context (the
52
+ evidence you judge influence on) and to flag drift (a `## Links` line with no frontmatter edge
53
+ behind it). To record a confirmed edge plus its rating and reason, run it with
54
+ `--record <id> --edge <sources|chain> --rating <high|medium|low> --reason "..." --confirm`. It never
55
+ grades influence itself, never writes an edge without `--confirm`, and never deletes.
56
+
57
+ ## Connection Types (the frontmatter edges)
22
58
 
23
- ### 2. Sources (Backward)
59
+ ### 1. Sources (Backward)
24
60
  The `sources` array in frontmatter. The direct ascendant(s) this intent was created from / emerged from the lifecycle of (formation, not topic similarity), backward links to the work it was built out of:
25
61
  ```yaml
26
62
  sources: ["1a", "1a2"]
27
63
  ```
28
64
 
29
- ### 3. Chain (Forward)
65
+ ### 2. Chain (Forward)
30
66
  The `chain` array in frontmatter. What this intent spawned AND related-but-not-spawned successors it leads to, forward links to children, follow-on, and related work:
31
67
  ```yaml
32
68
  chain: ["1b1", "1b2"]
33
69
  ```
34
70
 
35
- ### 4. Tags (Weakest)
36
- Shared tags in frontmatter enable filtered discovery. Use `project-<name>` tags for project membership.
71
+ ### 3. Tags (for discovery, not links)
72
+ Shared tags in frontmatter enable filtered discovery. Use `project-<name>` tags for project membership. A shared tag is a loose theme grouping: it earns NO edge.
37
73
  ```yaml
38
74
  tags: [plastic, project-reddit-kb]
39
75
  ```