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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/PLASTIC.md +131 -7
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-enforcer.md +1 -1
  4. package/agents/plastic-executor.md +11 -1
  5. package/agents/plastic-intent-curator.md +7 -5
  6. package/agents/plastic-planner.md +11 -1
  7. package/agents/plastic-spec-specialist.md +9 -1
  8. package/hooks/hooks.json +20 -0
  9. package/hooks/retrieval-gate +10 -0
  10. package/hooks/savepoint-pre +10 -0
  11. package/hooks/statusline +150 -41
  12. package/package.json +1 -1
  13. package/scripts/agent-report +163 -0
  14. package/scripts/doctor.rb +172 -0
  15. package/scripts/hook-auto-arm +1 -1
  16. package/scripts/hook-bash-gate +2 -2
  17. package/scripts/hook-code-gate +11 -6
  18. package/scripts/hook-create-gate +2 -2
  19. package/scripts/hook-gate-check +14 -23
  20. package/scripts/hook-retrieval-gate +136 -0
  21. package/scripts/hook-savepoint-pre +32 -0
  22. package/scripts/hook-session-start +1 -1
  23. package/scripts/insight-append +51 -0
  24. package/scripts/lib/bridge.rb +374 -34
  25. package/scripts/lib/frontmatter_writer.rb +130 -0
  26. package/scripts/lib/graph_rebuild.rb +328 -0
  27. package/scripts/lib/insights.rb +86 -0
  28. package/scripts/lib/installer_core.rb +23 -0
  29. package/scripts/lib/link_suggestions.rb +322 -0
  30. package/scripts/lib/links_projection.rb +160 -0
  31. package/scripts/lib/links_section.rb +207 -0
  32. package/scripts/lib/power_tools.rb +76 -0
  33. package/scripts/lib/qmd_hook.rb +38 -25
  34. package/scripts/lib/qmd_sync.rb +36 -0
  35. package/scripts/lib/retrieval_gate.rb +211 -0
  36. package/scripts/lib/worktree.rb +409 -0
  37. package/scripts/link-suggest +211 -0
  38. package/scripts/new-intent +138 -29
  39. package/scripts/project-links +287 -0
  40. package/scripts/qmd-sync +50 -3
  41. package/scripts/rebuild-graph +244 -0
  42. package/scripts/spawn-preamble +26 -1
  43. package/skills/auto/SKILL.md +58 -11
  44. package/skills/auto/evals/evals.json +48 -0
  45. package/skills/auto/references/agent-architecture.md +27 -4
  46. package/skills/auto/references/agent-report-contract.md +121 -0
  47. package/skills/brainstorming/SKILL.md +1 -0
  48. package/skills/brainstorming/evals/evals.json +22 -0
  49. package/skills/continuing/SKILL.md +30 -8
  50. package/skills/continuing/evals/evals.json +9 -0
  51. package/skills/creating-intent/SKILL.md +16 -2
  52. package/skills/creating-intent/evals/evals.json +16 -0
  53. package/skills/creating-intent/references/lifecycle.md +9 -4
  54. package/skills/creating-skills/SKILL.md +65 -0
  55. package/skills/creating-skills/evals/evals.json +108 -0
  56. package/skills/creating-skills/references/agents.md +168 -0
  57. package/skills/creating-skills/references/evals.md +41 -0
  58. package/skills/creating-skills/references/hooks.md +248 -0
  59. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  60. package/skills/creating-skills/references/scripts.md +166 -0
  61. package/skills/creating-skills/references/skills.md +165 -0
  62. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  63. package/skills/dashboard/SKILL.md +5 -0
  64. package/skills/dashboard/evals/evals.json +22 -0
  65. package/skills/executing-plan/SKILL.md +2 -2
  66. package/skills/humanizer/SKILL.md +39 -0
  67. package/skills/humanizer/evals/evals.json +70 -0
  68. package/skills/humanizer/references/always-on-snippet.md +9 -0
  69. package/skills/humanizer/references/examples.md +48 -0
  70. package/skills/intent-curator/SKILL.md +6 -1
  71. package/skills/intent-curator/evals/evals.json +22 -0
  72. package/skills/linking-intents/SKILL.md +54 -12
  73. package/skills/linking-intents/evals/evals.json +22 -0
  74. package/skills/linking-intents/references/zettelkasten.md +7 -0
  75. package/skills/managing-index/SKILL.md +8 -0
  76. package/skills/managing-index/evals/evals.json +22 -0
  77. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  78. package/skills/releasing/SKILL.md +32 -0
  79. package/skills/research/SKILL.md +8 -0
  80. package/skills/research/evals/evals.json +22 -0
  81. package/skills/writing-instructions/SKILL.md +0 -159
  82. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,76 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "qmd_sync"
5
+
6
+ # PowerTools — detect-then-degrade harness for Plastic's optional power-tools
7
+ # (intent 66b). It owns deterministic detection of each tool and builds an
8
+ # obligation ("mandate") string for whichever tools are present, so the agent is
9
+ # obliged (not merely reminded) to use them: QMD for finding intents, Serena for
10
+ # code navigation.
11
+ #
12
+ # Strictly detect-then-degrade: a tool that is absent contributes nothing, and
13
+ # `mandate` returns nil when no tool is present. Nothing here installs anything.
14
+ #
15
+ # Pure and dependency-injected: every detection runs through an injected callable
16
+ # or keyword probe (PATH scan / `.serena` marker walk), so the whole module is
17
+ # unit-testable with no real binaries, no network, and no global/ENV state.
18
+ module PowerTools
19
+ module_function
20
+
21
+ # True when QMD is present. Reuses QmdSync.detect (PATH probe), injectable.
22
+ def qmd?(detector: QmdSync.method(:detect))
23
+ !!detector.call
24
+ end
25
+
26
+ # True when Serena is present: a `.serena` directory exists in cwd or any
27
+ # ancestor, OR `serena` is resolvable on PATH. Both probes are injectable so
28
+ # tests do not depend on the host having Serena installed.
29
+ def serena?(cwd:, path_probe: method(:which_serena), marker_finder: method(:serena_marker?))
30
+ return true if marker_finder.call(cwd)
31
+ !!path_probe.call
32
+ end
33
+
34
+ # True when `serena` is an executable on PATH. Mirrors QmdSync.which_qmd.
35
+ def which_serena
36
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
37
+ candidate = File.join(dir, "serena")
38
+ File.file?(candidate) && File.executable?(candidate)
39
+ end
40
+ end
41
+
42
+ # Walk up from cwd to the filesystem root, returning true if any level holds a
43
+ # `.serena` directory.
44
+ def serena_marker?(cwd)
45
+ dir = File.expand_path(cwd)
46
+ loop do
47
+ return true if Dir.exist?(File.join(dir, ".serena"))
48
+ parent = File.dirname(dir)
49
+ break if parent == dir
50
+ dir = parent
51
+ end
52
+ false
53
+ end
54
+
55
+ # Obligation text for whichever tools are present, joined by newlines, or nil
56
+ # when none are. One MANDATORY line per present tool.
57
+ def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
58
+ lines = []
59
+
60
+ if qmd?(detector: qmd_detector)
61
+ lines << "MANDATORY: you MUST use QMD (`qmd search` / `qmd query` over the " \
62
+ "`plastic-*` collections) to check for an existing or related intent " \
63
+ "before treating this as new work; do not grep/Read the store first."
64
+ end
65
+
66
+ serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
67
+ if serena_present
68
+ lines << "MANDATORY: you MUST use Serena's symbolic tools (find_symbol / " \
69
+ "get_symbols_overview / find_referencing_symbols) for code navigation " \
70
+ "before grep/Read."
71
+ end
72
+
73
+ return nil if lines.empty?
74
+ lines.join("\n")
75
+ end
76
+ end
@@ -2,43 +2,56 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "qmd_sync"
5
+ require_relative "power_tools"
5
6
 
6
- # QmdHook — decision logic for the qmd-first UserPromptSubmit hook (intent 66).
7
- # Pure and dependency-injected: returns the additionalContext string to emit, or
8
- # nil to emit nothing. The executable hook wires real deps and prints; this is
9
- # unit-tested with a fake runner/detector (no real qmd, no network).
7
+ # QmdHook — decision logic for the power-tools UserPromptSubmit hook (intents 66,
8
+ # 66b). Pure and dependency-injected: returns the additionalContext string to
9
+ # emit, or nil to emit nothing. The executable hook wires real deps and prints;
10
+ # this is unit-tested with a fake runner/detector (no real qmd, no network).
11
+ #
12
+ # When qmd is present it still injects scored qmd hits (intent 66), then appends
13
+ # the PowerTools mandate (a MUST obligation per present tool: qmd for finding
14
+ # intents, serena for code navigation) instead of the old soft reminder.
10
15
  module QmdHook
11
16
  module_function
12
17
 
13
18
  MIN_PROMPT_LENGTH = 10
14
- REMINDER = "qmd is available: query it (`qmd search` / `qmd query` over the " \
15
- "`plastic-*` collections) before grep/Read when gathering intent " \
16
- "context (sources/chain) or checking whether this work already " \
17
- "exists as an intent."
18
19
 
19
20
  def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
20
- detector: QmdSync.method(:detect), limit: 3, min_score: 0.5)
21
- return nil unless detector.call
22
- p = prompt.to_s.strip
23
- return nil if p.length < MIN_PROMPT_LENGTH
24
- return nil if p.downcase == "continue"
21
+ detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
22
+ serena_detector: nil)
23
+ serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
24
+ qmd_present = !!detector.call
25
+ serena_present = !!serena_detector.call
26
+ return nil unless qmd_present || serena_present
25
27
 
26
- collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
27
- hits = QmdSync.search(p, collections: collections, limit: limit,
28
- min_score: min_score, runner: runner, detector: detector)
28
+ p = prompt.to_s.strip
29
+ # The hit SEARCH is the only expensive step and the only one gated by prompt
30
+ # triviality: skip it for short or bare-"continue" prompts (and when qmd is
31
+ # absent). The mandate itself is always-on for whichever tools are present.
32
+ search_ok = qmd_present && p.length >= MIN_PROMPT_LENGTH && p.downcase != "continue"
29
33
 
30
34
  parts = []
31
- if hits.any?
32
- parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
33
- "check before treating this as new work:"
34
- hits.each do |h|
35
- loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
36
- pct = (h[:score] * 100).round
37
- parts << "- [#{pct}%] #{loc} #{h[:title]}"
35
+ if search_ok
36
+ collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
37
+ hits = QmdSync.search(p, collections: collections, limit: limit,
38
+ min_score: min_score, runner: runner, detector: detector)
39
+ if hits.any?
40
+ parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
41
+ "check before treating this as new work:"
42
+ hits.each do |h|
43
+ loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
44
+ pct = (h[:score] * 100).round
45
+ parts << "- [#{pct}%] #{loc} — #{h[:title]}"
46
+ end
47
+ parts << ""
38
48
  end
39
- parts << ""
40
49
  end
41
- parts << REMINDER
50
+
51
+ mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
52
+ serena_detector: -> { serena_present })
53
+ parts << mandate if mandate
54
+ return nil if parts.empty?
42
55
  parts.join("\n")
43
56
  end
44
57
  end
@@ -99,6 +99,42 @@ module QmdSync
99
99
  { ran: true, ok: (ok1 && ok2) }
100
100
  end
101
101
 
102
+ # Non-blocking reindex for the completion path. QMD has no incremental reindex
103
+ # (`qmd update` is a full rescan, `qmd embed -c` re-embeds the whole collection,
104
+ # ~2 min), so running it inline would block the agent's turn; spawning detached
105
+ # returns immediately. No-op when qmd absent. Spawner/detector are injected so
106
+ # tests assert behavior with no real qmd and no real spawned process.
107
+ def reindex_async(collection:, detector: method(:detect), spawner: method(:default_async_spawner))
108
+ return skip_result unless detector.call
109
+ pid = spawner.call(collection)
110
+ { ran: true, async: true, pid: pid }
111
+ end
112
+
113
+ # Default spawner: launch `qmd update && qmd embed -c <collection>` detached and
114
+ # non-blocking, discarding output, then detach so it never blocks the turn.
115
+ def default_async_spawner(collection)
116
+ require "shellwords"
117
+ cmd = "qmd update && qmd embed -c #{Shellwords.escape(collection)}"
118
+ pid = Process.spawn(cmd, out: File::NULL, err: File::NULL, pgroup: true)
119
+ Process.detach(pid)
120
+ pid
121
+ end
122
+
123
+ # True when the QMD index has no pending (unembedded) documents. Binary
124
+ # freshness signal for the retrieval gate (intent 84, Lever 2). `qmd status` is
125
+ # plain text (no --json); it prints a line like "Pending: N need embedding".
126
+ # No pending line found -> treat as fresh (conservative: a parse miss must not
127
+ # block reads). Runner failure -> false (cannot confirm freshness). The caller
128
+ # gates on `detect` first, so absence is handled upstream; this only answers
129
+ # "is the present index fresh?". Pure via the injected runner.
130
+ def self.fresh?(runner: default_runner)
131
+ out, ok = runner.call(["status"])
132
+ return false unless ok && out
133
+ m = out[/^\s*Pending:\s*(\d+)\b/i, 1]
134
+ pending = m ? m.to_i : 0
135
+ pending.zero?
136
+ end
137
+
102
138
  # Read-only status used by doctor and the session-start report line.
103
139
  # Returns a structured hash; never mutates the index.
104
140
  def status(plastic_home:, runner: default_runner, detector: method(:detect))
@@ -0,0 +1,211 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "bridge"
5
+
6
+ # RetrievalGate — the single, pure decision for Lever 2 of intent 84, redesigned
7
+ # operation-based in intent 89a.
8
+ #
9
+ # Given an agent tool call (Bash/Read/Grep/Glob) and injected capability signals,
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).
15
+ #
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).
23
+ #
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)
27
+ #
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).
31
+ #
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).
38
+ module RetrievalGate
39
+ module_function
40
+
41
+ # A `# qmd-ok` token that is a real TRAILING shell comment, after stripping a
42
+ # trailing newline. The token must be preceded by whitespace (or start the
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.
45
+ BYPASS_RE = /(?:\A|\s)#\s*qmd-ok\s*\z/.freeze
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
+
52
+ # Decide. Returns nil to ALLOW, or a reason String to BLOCK.
53
+ # capabilities: { qmd:, qmd_fresh: } (booleans).
54
+ # reindex: no-arg callable fired once when a QMD-class target is STALE.
55
+ # When bypassed, returns nil and (if given) yields :bypass to the optional block
56
+ # so the caller can log it.
57
+ def decision(tool_name:, tool_input:, plastic_home:, cwd:,
58
+ capabilities:, reindex: -> {})
59
+ targets = extract_targets(tool_name, tool_input, cwd: cwd)
60
+ return nil if targets.empty?
61
+
62
+ if bypass?(tool_name, tool_input)
63
+ yield(:bypass) if block_given?
64
+ return nil
65
+ end
66
+
67
+ stale_seen = false
68
+ targets.each do |path|
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
75
+ end
76
+ # absent/broken -> allow this target
77
+ end
78
+
79
+ reindex.call if stale_seen
80
+ nil
81
+ end
82
+
83
+ # --- classification ---
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.
87
+ def classify(path, plastic_home:)
88
+ return :allow if path.nil? || path.empty?
89
+ store_path?(path, plastic_home: plastic_home) ? :qmd : :allow
90
+ end
91
+
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:)
96
+ abs = absolutize(path)
97
+ home = File.expand_path(plastic_home)
98
+ global = File.join(home, "store")
99
+ return true if abs == global || abs.start_with?("#{global}/")
100
+
101
+ projects = File.join(home, "projects")
102
+ return false unless abs.start_with?("#{projects}/")
103
+ tail = abs[(projects.length + 1)..].to_s.split(File::SEPARATOR)
104
+ tail.length >= 2 && tail[1] == "store"
105
+ end
106
+
107
+ def absolutize(path)
108
+ File.absolute_path?(path) ? path : File.expand_path(path)
109
+ end
110
+
111
+ # --- bypass ---
112
+
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.
115
+ def bypass?(tool_name, tool_input)
116
+ return false unless tool_name.to_s == "Bash"
117
+ cmd = tool_input.is_a?(Hash) ? tool_input["command"].to_s : ""
118
+ BYPASS_RE.match?(cmd.chomp)
119
+ end
120
+
121
+ # --- target extraction ---
122
+
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.
127
+ def extract_targets(tool_name, tool_input, cwd:)
128
+ input = tool_input.is_a?(Hash) ? tool_input : {}
129
+ case tool_name.to_s
130
+ when "Grep"
131
+ # The search root is the target; the query text is not a path.
132
+ [input["path"]].compact.reject { |s| s.to_s.empty? }
133
+ when "Bash"
134
+ bash_search_targets(input["command"].to_s)
135
+ else
136
+ # Read, Glob, and every other tool: read / structural op -> never gated.
137
+ []
138
+ end
139
+ end
140
+
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)
144
+ return [] unless command.is_a?(String) && !command.empty?
145
+ targets = []
146
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
147
+ targets.concat(segment_search_targets(segment))
148
+ end
149
+ targets.reject { |t| t.nil? || t.empty? || dev_path?(t) }.uniq
150
+ end
151
+
152
+ def segment_search_targets(segment)
153
+ tokens = tokenize(segment)
154
+ return [] if tokens.empty?
155
+
156
+ # Skip leading env-style assignments (FOO=bar cmd ...).
157
+ idx = 0
158
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /\A-/
159
+ util = File.basename(tokens[idx].to_s)
160
+ return [] unless CONTENT_SEARCH_UTILS.include?(util)
161
+
162
+ args = tokens[(idx + 1)..] || []
163
+ path_args_for(args)
164
+ end
165
+
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)
169
+ paths = []
170
+ pattern_consumed = false
171
+ args.each do |a|
172
+ next if a.start_with?("-")
173
+ unless pattern_consumed
174
+ pattern_consumed = true
175
+ next
176
+ end
177
+ paths << a
178
+ end
179
+ paths
180
+ end
181
+
182
+ # Minimal tokenizer: split on whitespace, strip surrounding matching quotes off
183
+ # each token. Good enough for the conservative read-vector parse.
184
+ def tokenize(segment)
185
+ segment.to_s.strip.split(/\s+/).map { |t| strip_quotes(t) }
186
+ end
187
+
188
+ def strip_quotes(token)
189
+ if (token.start_with?('"') && token.end_with?('"')) ||
190
+ (token.start_with?("'") && token.end_with?("'"))
191
+ token[1..-2].to_s
192
+ else
193
+ token
194
+ end
195
+ end
196
+
197
+ def dev_path?(path)
198
+ path == "/dev/null" || path.start_with?("/dev/")
199
+ end
200
+
201
+ # --- reasons ---
202
+
203
+ def qmd_reason(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."
210
+ end
211
+ end