@zalom/plastic 1.0.0-alpha.35 → 1.0.0-alpha.37

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.37",
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
+ )
@@ -10,6 +10,14 @@ require "digest"
10
10
  module Bridge
11
11
  STAGES = %w[what why how exec done].freeze
12
12
 
13
+ # Stale-bridge purge window (intent 67). The bridge file is ephemeral
14
+ # live-session gate state, NOT a continuation source: an intent is resumed from
15
+ # its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
16
+ # this window is dead weight and safe to purge, regardless of arm state. No
17
+ # real session stays live for two days, so a 48h cutoff never removes a bridge
18
+ # an active run depends on.
19
+ PURGE_AGE_SECONDS = 48 * 3600 # 48 hours
20
+
13
21
  def self.intent_file(intent_dir)
14
22
  dir_name = File.basename(intent_dir)
15
23
  "#{intent_dir}/#{dir_name}.md"
@@ -106,6 +114,40 @@ module Bridge
106
114
  pool.max_by { |c| c[:mtime] }&.fetch(:data)
107
115
  end
108
116
 
117
+ # --- Stale-bridge purge (intent 67) ---------------------------------------
118
+ #
119
+ # Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
120
+ # scan stays bounded. Best-effort and non-raising: returns the array of removed
121
+ # paths. Continuation does not depend on these files (an intent resumes from its
122
+ # savepoint.md ledger), so the only safety rule is age: a bridge older than
123
+ # max_age_seconds is purged regardless of arm state, while anything newer is kept
124
+ # (it may be a live run). The current session's own bridge is never purged
125
+ # (preserves the disarm_auto contract that it stays readable). Wired into
126
+ # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
127
+ # clean.
128
+ def self.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
129
+ tmp: tmp_dir)
130
+ current = path(session, tmp: tmp)
131
+ removed = []
132
+ Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
133
+ next if f == current
134
+ begin
135
+ next if (now - File.mtime(f)) < max_age_seconds
136
+ File.delete(f)
137
+ removed << f
138
+ rescue Errno::ENOENT
139
+ # Raced with another job that already removed it; count as purged.
140
+ removed << f
141
+ rescue => e
142
+ $stderr.puts "plastic: purge skipped #{f}: #{e.message}"
143
+ end
144
+ end
145
+ removed
146
+ rescue => e
147
+ $stderr.puts "plastic: purge_stale_bridges failed: #{e.message}"
148
+ removed || []
149
+ end
150
+
109
151
  def self.read(session, tmp: tmp_dir)
110
152
  p = path(session, tmp: tmp)
111
153
  return nil unless File.exist?(p)
@@ -323,6 +365,7 @@ module Bridge
323
365
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
324
366
  data["build"]["auto"] = true
325
367
  write(key, data)
368
+ purge_stale_bridges(session: key)
326
369
  data
327
370
  end
328
371
 
@@ -333,6 +376,7 @@ module Bridge
333
376
  data["build"] ||= {}
334
377
  data["build"]["auto"] = false
335
378
  write(session, data)
379
+ purge_stale_bridges(session: session)
336
380
  data
337
381
  end
338
382
 
@@ -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
@@ -194,6 +194,8 @@ During initial project creation, all decisions are non-destructive by definition
194
194
  ```bash
195
195
  ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_SESSION_ID"])'
196
196
  ```
197
+ Disarming also purges stale bridge files from the temp directory automatically (it keeps the
198
+ current bridge and any live run), so no manual `/tmp` cleanup is needed.
197
199
  10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
198
200
 
199
201
  ## Error Handling