@zalom/plastic 1.0.0-alpha.35 → 1.0.0-alpha.36
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/hooks/hooks.json +10 -0
- package/hooks/qmd-search +8 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +1 -0
- package/scripts/hook-qmd-search +44 -0
- package/scripts/lib/installer_core.rb +3 -0
- package/scripts/lib/qmd_hook.rb +44 -0
- package/scripts/lib/qmd_sync.rb +50 -0
package/hooks/hooks.json
CHANGED
|
@@ -93,6 +93,16 @@
|
|
|
93
93
|
"statusMessage": "Checking auto mode..."
|
|
94
94
|
}
|
|
95
95
|
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"matcher": "",
|
|
99
|
+
"hooks": [
|
|
100
|
+
{
|
|
101
|
+
"type": "command",
|
|
102
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" qmd-search",
|
|
103
|
+
"statusMessage": "Searching QMD..."
|
|
104
|
+
}
|
|
105
|
+
]
|
|
96
106
|
}
|
|
97
107
|
]
|
|
98
108
|
}
|
package/hooks/qmd-search
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# qmd-first search hook launcher (intent 66). No-op when there is no global store.
|
|
3
|
+
GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
|
|
4
|
+
if [ ! -f "$GLOBAL_INDEX" ]; then
|
|
5
|
+
exit 0
|
|
6
|
+
fi
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
exec ruby "$SCRIPT_DIR/../scripts/hook-qmd-search" "$HOME/.plastic"
|
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# qmd-first UserPromptSubmit hook (intent 66). Reads the prompt from stdin JSON,
|
|
6
|
+
# delegates the decision to QmdHook.run under a hard timeout, and prints
|
|
7
|
+
# hookSpecificOutput when there is something to inject/remind. Silent no-op (exit
|
|
8
|
+
# 0) when qmd is absent, the prompt is trivial, or anything goes wrong. ARGV[0]
|
|
9
|
+
# is the plastic_home (passed by the launcher); defaults to ~/.plastic.
|
|
10
|
+
require "json"
|
|
11
|
+
require "timeout"
|
|
12
|
+
require_relative "lib/qmd_hook"
|
|
13
|
+
|
|
14
|
+
raw = begin
|
|
15
|
+
STDIN.read
|
|
16
|
+
rescue StandardError
|
|
17
|
+
""
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
prompt = begin
|
|
21
|
+
parsed = JSON.parse(raw)
|
|
22
|
+
parsed.is_a?(Hash) ? parsed["user_prompt"].to_s : ""
|
|
23
|
+
rescue StandardError
|
|
24
|
+
""
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
28
|
+
|
|
29
|
+
context = begin
|
|
30
|
+
Timeout.timeout(2) do
|
|
31
|
+
QmdHook.run(prompt: prompt, cwd: Dir.pwd, plastic_home: plastic_home)
|
|
32
|
+
end
|
|
33
|
+
rescue Exception
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
exit 0 if context.nil? || context.strip.empty?
|
|
38
|
+
|
|
39
|
+
puts JSON.generate(
|
|
40
|
+
"hookSpecificOutput" => {
|
|
41
|
+
"hookEventName" => "UserPromptSubmit",
|
|
42
|
+
"additionalContext" => context,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
@@ -199,6 +199,8 @@ class InstallerCore
|
|
|
199
199
|
"scripts/hook-continue" => "scripts/hook-continue",
|
|
200
200
|
"scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
|
|
201
201
|
"scripts/hook-gate-check" => "scripts/hook-gate-check",
|
|
202
|
+
"scripts/hook-qmd-search" => "scripts/hook-qmd-search",
|
|
203
|
+
"scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
|
|
202
204
|
"scripts/hook-code-gate" => "scripts/hook-code-gate",
|
|
203
205
|
"scripts/hook-bash-gate" => "scripts/hook-bash-gate",
|
|
204
206
|
"scripts/hook-auto-arm" => "scripts/hook-auto-arm",
|
|
@@ -543,6 +545,7 @@ class InstallerCore
|
|
|
543
545
|
{ "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
|
|
544
546
|
{ "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
|
|
545
547
|
{ "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
|
|
548
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
|
|
546
549
|
],
|
|
547
550
|
},
|
|
548
551
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "qmd_sync"
|
|
5
|
+
|
|
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).
|
|
10
|
+
module QmdHook
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
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
|
+
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"
|
|
25
|
+
|
|
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)
|
|
29
|
+
|
|
30
|
+
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]}"
|
|
38
|
+
end
|
|
39
|
+
parts << ""
|
|
40
|
+
end
|
|
41
|
+
parts << REMINDER
|
|
42
|
+
parts.join("\n")
|
|
43
|
+
end
|
|
44
|
+
end
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "yaml"
|
|
5
|
+
require "json"
|
|
5
6
|
|
|
6
7
|
# QmdSync — the single place Plastic talks to QMD (intent 45a).
|
|
7
8
|
#
|
|
@@ -111,6 +112,55 @@ module QmdSync
|
|
|
111
112
|
missing: missing, all_registered: missing.empty? }
|
|
112
113
|
end
|
|
113
114
|
|
|
115
|
+
# Read-only BM25 search over one or more collections. Returns hits sorted by
|
|
116
|
+
# score (desc), filtered by min_score, capped at limit:
|
|
117
|
+
# [{ score: Float, file: String, line: Integer|nil, title: String, snippet: String }, ...]
|
|
118
|
+
# No-ops to [] when qmd is absent or the query is blank. Uses `qmd search --json`
|
|
119
|
+
# (BM25, no embeddings / no model downloads). Pure via the injected runner.
|
|
120
|
+
def search(query, collections:, limit: 3, min_score: 0.5, runner: default_runner, detector: method(:detect))
|
|
121
|
+
return [] unless detector.call
|
|
122
|
+
q = query.to_s.strip
|
|
123
|
+
return [] if q.empty? || Array(collections).empty?
|
|
124
|
+
|
|
125
|
+
args = ["search", q]
|
|
126
|
+
Array(collections).each { |c| args.concat(["-c", c]) }
|
|
127
|
+
args << "--json"
|
|
128
|
+
|
|
129
|
+
out, ok = runner.call(args)
|
|
130
|
+
return [] unless ok && out && !out.strip.empty?
|
|
131
|
+
|
|
132
|
+
parsed = begin
|
|
133
|
+
JSON.parse(out)
|
|
134
|
+
rescue StandardError
|
|
135
|
+
return []
|
|
136
|
+
end
|
|
137
|
+
return [] unless parsed.is_a?(Array)
|
|
138
|
+
|
|
139
|
+
parsed.filter_map do |h|
|
|
140
|
+
next unless h.is_a?(Hash)
|
|
141
|
+
score = h["score"].to_f
|
|
142
|
+
next if score < min_score
|
|
143
|
+
{ score: score, file: h["file"].to_s, line: h["line"],
|
|
144
|
+
title: h["title"].to_s, snippet: h["snippet"].to_s }
|
|
145
|
+
end.sort_by { |h| -h[:score] }.first(limit)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Which collections to search for a given working directory: the matched
|
|
149
|
+
# project's collection plus plastic-global, or just plastic-global when the
|
|
150
|
+
# CWD is not inside any registered project. Project match = CWD equals the
|
|
151
|
+
# registered path or is nested under it.
|
|
152
|
+
def collections_for_cwd(cwd, plastic_home:)
|
|
153
|
+
cwd = File.expand_path(cwd)
|
|
154
|
+
projects = load_projects(plastic_home)
|
|
155
|
+
slug, = projects.find do |_s, info|
|
|
156
|
+
path = info.is_a?(Hash) ? info["path"] : nil
|
|
157
|
+
next false unless path
|
|
158
|
+
root = File.expand_path(path)
|
|
159
|
+
cwd == root || cwd.start_with?(root + File::SEPARATOR)
|
|
160
|
+
end
|
|
161
|
+
slug ? ["plastic-#{slug}", "plastic-global"] : ["plastic-global"]
|
|
162
|
+
end
|
|
163
|
+
|
|
114
164
|
# --- internals ---
|
|
115
165
|
|
|
116
166
|
def skip_result
|