@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 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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.35",
3
+ "version": "1.0.0-alpha.36",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -42,6 +42,7 @@ class Doctor
42
42
  plastic-gate-check
43
43
  plastic-continue
44
44
  plastic-future-intent-check
45
+ plastic-qmd-search
45
46
  ].freeze
46
47
 
47
48
  CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
@@ -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
@@ -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