@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 +41 -14
- package/package.json +1 -1
- package/scripts/hook-retrieval-gate +31 -17
- package/scripts/lib/installer_core.rb +2 -0
- package/scripts/lib/link_suggestions.rb +322 -0
- package/scripts/lib/retrieval_gate.rb +76 -103
- package/scripts/link-suggest +211 -0
- package/skills/creating-intent/SKILL.md +8 -2
- package/skills/linking-intents/SKILL.md +48 -12
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).
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
absent
|
|
251
|
-
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
@@ -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
|
|
6
|
-
# stdin: tool_name + tool_input), computes
|
|
7
|
-
#
|
|
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
|
|
32
|
+
# with no real qmd. Returns [exit_code, stderr_string].
|
|
29
33
|
# stdin: raw PreToolUse JSON
|
|
30
|
-
# capabilities: { qmd:, qmd_fresh
|
|
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.
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
Timeout.timeout(2) {
|
|
83
|
+
begin
|
|
84
|
+
qmd_fresh = Timeout.timeout(2) { fresh.call }
|
|
74
85
|
rescue StandardError
|
|
75
|
-
#
|
|
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
|
-
|
|
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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
16
|
-
# -
|
|
17
|
-
# -
|
|
18
|
-
# -
|
|
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
|
-
#
|
|
21
|
-
# -
|
|
22
|
-
#
|
|
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
|
-
#
|
|
26
|
-
#
|
|
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
|
-
#
|
|
29
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
#
|
|
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
|
|
143
|
-
#
|
|
144
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
163
|
-
#
|
|
164
|
-
|
|
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(
|
|
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
|
|
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
|
|
160
|
+
return [] unless CONTENT_SEARCH_UTILS.include?(util)
|
|
184
161
|
|
|
185
162
|
args = tokens[(idx + 1)..] || []
|
|
186
|
-
path_args_for(
|
|
163
|
+
path_args_for(args)
|
|
187
164
|
end
|
|
188
165
|
|
|
189
|
-
# Collect path-shaped arguments for a
|
|
190
|
-
#
|
|
191
|
-
def path_args_for(
|
|
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
|
-
|
|
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
|
|
229
|
-
"
|
|
230
|
-
"`
|
|
231
|
-
"
|
|
232
|
-
|
|
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
|
|
66
|
-
|
|
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
|
|
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
|
-
##
|
|
13
|
+
## Discovery and ranking are separate
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
Explicit wikilinks in the `## Links` section. Bidirectional — add to both intents.
|
|
15
|
+
Two distinct steps, do not conflate them:
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
```
|