@zalom/plastic 1.0.0-alpha.26 → 1.0.0-alpha.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.26",
3
+ "version": "1.0.0-alpha.28",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -15,6 +15,8 @@ require "time"
15
15
  require "date"
16
16
  require "digest"
17
17
 
18
+ require_relative "lib/qmd_sync"
19
+
18
20
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
19
21
  # run it hermetically (no eval, no global-constant rewriting).
20
22
  class Doctor
@@ -1005,6 +1007,48 @@ class Doctor
1005
1007
  a_pre <=> b_pre
1006
1008
  end
1007
1009
 
1010
+ # --- Check category: QMD integration (read-only, optional) ---
1011
+ #
1012
+ # QMD is an optional integration. When `qmd` is not on PATH we emit a single
1013
+ # passing check and never fail — its absence is not a Plastic health problem.
1014
+ # When present, we report whether every Plastic store is registered as a QMD
1015
+ # collection. Everything here is read-only; we never invoke a mutating qmd
1016
+ # subcommand. detector/runner are injectable so tests stay hermetic.
1017
+ def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
1018
+ return [absent_qmd_check] unless detector.call
1019
+
1020
+ checks = [check(
1021
+ category: "qmd", name: "present", status: "pass",
1022
+ message: "QMD installed"
1023
+ )]
1024
+
1025
+ status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
1026
+ missing = status[:missing] || []
1027
+
1028
+ if status[:all_registered]
1029
+ checks << check(
1030
+ category: "qmd", name: "collections", status: "pass",
1031
+ message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
1032
+ )
1033
+ else
1034
+ checks << check(
1035
+ category: "qmd", name: "collections", status: "warn",
1036
+ message: "#{missing.size} Plastic store(s) not registered as QMD collections",
1037
+ details: missing,
1038
+ fixable: true, fix_hint: "Run: qmd-sync register --all"
1039
+ )
1040
+ end
1041
+
1042
+ checks
1043
+ end
1044
+
1045
+ def absent_qmd_check
1046
+ check(
1047
+ category: "qmd", name: "present", status: "pass",
1048
+ message: "QMD not installed (optional integration)"
1049
+ )
1050
+ end
1051
+
1008
1052
  # --- Run all checks ---
1009
1053
 
1010
1054
  def run_checks(agent_key)
@@ -1015,6 +1059,7 @@ class Doctor
1015
1059
  all_checks += check_core_files(agent_key)
1016
1060
  all_checks += check_project_stores
1017
1061
  all_checks += check_deprecations
1062
+ all_checks += check_qmd
1018
1063
 
1019
1064
  summarize(all_checks, agent_key)
1020
1065
  end
@@ -8,6 +8,7 @@ require "date"
8
8
  require "yaml"
9
9
  require_relative "lib/bridge"
10
10
  require_relative "lib/boot_banner"
11
+ require_relative "lib/qmd_sync"
11
12
  require_relative "doctor"
12
13
 
13
14
  index_path, store_root, mode, plugin_root = ARGV
@@ -204,6 +205,25 @@ parts = []
204
205
  parts << core_banner
205
206
  parts << ""
206
207
 
208
+ # --- QMD search status (intent 45a, READ-ONLY) ---
209
+ # Report-only line for the model (additionalContext), never systemMessage and
210
+ # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
211
+ # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
212
+ # slow/broken/missing qmd appends nothing and the hook continues cleanly.
213
+ begin
214
+ require "timeout"
215
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: store_root) }
216
+ if qmd_status[:present]
217
+ if qmd_status[:all_registered]
218
+ parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
219
+ else
220
+ parts << "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
221
+ end
222
+ end
223
+ rescue Exception
224
+ # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
225
+ end
226
+
207
227
  if plastic_md
208
228
  # Conventions always loaded first
209
229
  parts << plastic_md
@@ -204,6 +204,8 @@ class InstallerCore
204
204
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
205
205
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
206
206
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
207
+ "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
208
+ "scripts/qmd-sync" => "scripts/qmd-sync",
207
209
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
208
210
  "scripts/install.rb" => "scripts/install.rb",
209
211
  "scripts/update.rb" => "scripts/update.rb",
@@ -0,0 +1,159 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # QmdSync — the single place Plastic talks to QMD (intent 45a).
7
+ #
8
+ # Plastic computes only the Plastic-specific inputs (which store directory maps to
9
+ # which `plastic-`prefixed collection, derived from projects.yml, plus a short
10
+ # context description). The actual indexing is delegated to the `qmd` CLI; this
11
+ # module never reimplements QMD's logic. QMD is optional: every public entry
12
+ # no-ops cleanly when `qmd` is not on PATH.
13
+ #
14
+ # Pure and dependency-injected: all shelling-out goes through an injected
15
+ # `runner` callable, so the whole module is unit-testable with no real binary,
16
+ # no network, and no model downloads. The default runner shells out to `qmd`.
17
+ module QmdSync
18
+ module_function
19
+
20
+ # A runner is `->(args_array) { [stdout_string, success_boolean] }`.
21
+ # The default invokes the real `qmd` binary.
22
+ def default_runner
23
+ lambda do |args|
24
+ require "open3"
25
+ out, _err, status = Open3.capture3("qmd", *args)
26
+ [out, status.success?]
27
+ end
28
+ end
29
+
30
+ # True when `qmd` is resolvable on PATH. The probe is injectable so tests do
31
+ # not depend on the host having qmd installed.
32
+ def detect(path_probe: method(:which_qmd))
33
+ !!path_probe.call
34
+ end
35
+
36
+ def which_qmd
37
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
38
+ candidate = File.join(dir, "qmd")
39
+ File.file?(candidate) && File.executable?(candidate)
40
+ end
41
+ end
42
+
43
+ # Collection name for a store directory.
44
+ # global store (<plastic_home>/store) -> "plastic-global"
45
+ # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
46
+ # Slug is resolved from projects.yml by matching the project path; falls back
47
+ # to the directory's parent name when no registry match exists.
48
+ def collection_name(store_dir, plastic_home:)
49
+ store_dir = File.expand_path(store_dir)
50
+ global_store = File.expand_path(File.join(plastic_home, "store"))
51
+ return "plastic-global" if store_dir == global_store
52
+
53
+ slug = slug_for_store(store_dir, plastic_home: plastic_home)
54
+ "plastic-#{slug}"
55
+ end
56
+
57
+ # Every store Plastic knows about: the global store plus each registered
58
+ # project store. Returns [{collection:, dir:}, ...].
59
+ def enumerate_stores(plastic_home:)
60
+ stores = [{
61
+ collection: "plastic-global",
62
+ dir: File.expand_path(File.join(plastic_home, "store")),
63
+ }]
64
+
65
+ projects = load_projects(plastic_home)
66
+ projects.each do |slug, info|
67
+ path = info.is_a?(Hash) ? info["path"] : nil
68
+ next unless path
69
+ project_store = File.join(File.expand_path(path), "store")
70
+ # Project stores live under ~/.plastic/projects/<slug>/store as the mirror;
71
+ # registry `path` is the project code dir, so the tactical store is the
72
+ # plastic_home projects mirror.
73
+ mirror_store = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
74
+ dir = Dir.exist?(mirror_store) ? mirror_store : project_store
75
+ stores << { collection: "plastic-#{slug}", dir: dir }
76
+ end
77
+ stores
78
+ end
79
+
80
+ # Register a store directory as a collection. Idempotent: re-running is safe.
81
+ # `qmd collection add` exits non-zero when the collection already exists, so we
82
+ # check the collection list first and treat an existing collection as success.
83
+ # No-op when qmd is absent. (`qmd context add` attaches a summary to a document
84
+ # path, not a collection-level description, so it is not used here.)
85
+ def register(collection:, dir:, runner: default_runner, detector: method(:detect))
86
+ return skip_result unless detector.call
87
+ return { ran: true, ok: true, output: "exists" } if list_collections(runner).include?(collection)
88
+ out, ok = runner.call(["collection", "add", dir, "--name", collection])
89
+ { ran: true, ok: ok, output: out.to_s.strip }
90
+ end
91
+
92
+ # Re-index a single collection: refresh the corpus then its embeddings.
93
+ # Scoped embed (-c) keeps delivery-time reindex fast. No-op when qmd absent.
94
+ def reindex(collection:, runner: default_runner, detector: method(:detect))
95
+ return skip_result unless detector.call
96
+ _o1, ok1 = runner.call(["update"])
97
+ _o2, ok2 = runner.call(["embed", "-c", collection])
98
+ { ran: true, ok: (ok1 && ok2) }
99
+ end
100
+
101
+ # Read-only status used by doctor and the session-start report line.
102
+ # Returns a structured hash; never mutates the index.
103
+ def status(plastic_home:, runner: default_runner, detector: method(:detect))
104
+ return { present: false } unless detector.call
105
+
106
+ expected = enumerate_stores(plastic_home: plastic_home).map { |s| s[:collection] }
107
+ listed = list_collections(runner)
108
+ missing = expected - listed
109
+
110
+ { present: true, expected: expected, registered: listed,
111
+ missing: missing, all_registered: missing.empty? }
112
+ end
113
+
114
+ # --- internals ---
115
+
116
+ def skip_result
117
+ { ran: false, ok: true, skipped: true }
118
+ end
119
+
120
+ def list_collections(runner)
121
+ out, ok = runner.call(["collection", "list"])
122
+ return [] unless ok && out
123
+ # qmd prints lines like "plastic-global (qmd://plastic-global/)"; pull the
124
+ # leading collection token off each non-indented line.
125
+ out.lines.filter_map do |line|
126
+ next if line.start_with?(" ", "\t")
127
+ m = line.strip.match(/\A([A-Za-z0-9][\w.-]*)\b/)
128
+ m && m[1]
129
+ end.reject { |t| %w[Collections No].include?(t) }
130
+ end
131
+
132
+ def load_projects(plastic_home)
133
+ path = File.join(plastic_home, "projects.yml")
134
+ return {} unless File.exist?(path)
135
+ data = begin
136
+ YAML.safe_load(File.read(path)) || {}
137
+ rescue StandardError
138
+ {}
139
+ end
140
+ projects = data.is_a?(Hash) ? data["projects"] : nil
141
+ projects.is_a?(Hash) ? projects : {}
142
+ end
143
+
144
+ def slug_for_store(store_dir, plastic_home:)
145
+ projects = load_projects(plastic_home)
146
+ projects.each do |slug, info|
147
+ path = info.is_a?(Hash) ? info["path"] : nil
148
+ next unless path
149
+ project_root = File.expand_path(path)
150
+ mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
151
+ return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
152
+ end
153
+ # Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
154
+ parts = store_dir.split(File::SEPARATOR)
155
+ idx = parts.rindex("projects")
156
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
157
+ File.basename(File.dirname(store_dir))
158
+ end
159
+ end
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # qmd-sync — deterministic CLI over QmdSync (intent 45a).
6
+ #
7
+ # Plastic skills and hooks call these verbs instead of assembling `qmd` commands
8
+ # themselves. QMD is optional: when `qmd` is absent every verb exits 0 and prints
9
+ # a skip notice, so callers can invoke unconditionally.
10
+ #
11
+ # Usage:
12
+ # qmd-sync detect # exit 0 if qmd present, 1 if absent
13
+ # qmd-sync register --store <dir> # register one store as a collection
14
+ # qmd-sync register --all # register the global store + all projects
15
+ # qmd-sync reindex --store <dir> # update + embed that store's collection
16
+ # qmd-sync status [--format json] # read-only status
17
+ #
18
+ # --home <path> overrides the Plastic home (default: ~/.plastic).
19
+
20
+ require "json"
21
+ require_relative "lib/qmd_sync"
22
+
23
+ def plastic_home(args)
24
+ if (i = args.index("--home")) && args[i + 1]
25
+ File.expand_path(args[i + 1])
26
+ else
27
+ File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
28
+ end
29
+ end
30
+
31
+ def opt(args, name)
32
+ (i = args.index(name)) && args[i + 1]
33
+ end
34
+
35
+ verb = ARGV.shift
36
+ home = plastic_home(ARGV)
37
+
38
+ unless QmdSync.detect
39
+ case verb
40
+ when "detect"
41
+ warn "QMD not detected on PATH."
42
+ exit 1
43
+ else
44
+ puts "QMD not detected; skipped (#{verb})."
45
+ exit 0
46
+ end
47
+ end
48
+
49
+ case verb
50
+ when "detect"
51
+ puts "QMD detected."
52
+ exit 0
53
+
54
+ when "register"
55
+ stores =
56
+ if ARGV.include?("--all")
57
+ QmdSync.enumerate_stores(plastic_home: home)
58
+ elsif (dir = opt(ARGV, "--store"))
59
+ [{ collection: QmdSync.collection_name(dir, plastic_home: home), dir: File.expand_path(dir) }]
60
+ else
61
+ warn "register: pass --store <dir> or --all"
62
+ exit 2
63
+ end
64
+ stores.each do |s|
65
+ next unless Dir.exist?(s[:dir])
66
+ res = QmdSync.register(collection: s[:collection], dir: s[:dir])
67
+ puts "registered #{s[:collection]} -> #{s[:dir]} (#{res[:ok] ? "ok" : "warn"})"
68
+ end
69
+ exit 0
70
+
71
+ when "reindex"
72
+ dir = opt(ARGV, "--store") or (warn("reindex: pass --store <dir>"); exit 2)
73
+ collection = QmdSync.collection_name(dir, plastic_home: home)
74
+ res = QmdSync.reindex(collection: collection)
75
+ puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
76
+ exit 0
77
+
78
+ when "status"
79
+ st = QmdSync.status(plastic_home: home)
80
+ if opt(ARGV, "--format") == "json"
81
+ puts JSON.generate(st)
82
+ else
83
+ puts "QMD present: #{st[:present]}"
84
+ puts "registered: #{Array(st[:registered]).join(", ")}"
85
+ puts "missing: #{Array(st[:missing]).join(", ")}" unless Array(st[:missing]).empty?
86
+ end
87
+ exit 0
88
+
89
+ else
90
+ warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status."
91
+ exit 2
92
+ end
@@ -161,11 +161,17 @@ During initial project creation, all decisions are non-destructive by definition
161
161
  - Update `chain` in the current intent's frontmatter
162
162
  6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
163
163
  7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
164
- 8. Disarm the lifecycle gate (auto delivery is finished):
164
+ 8. Refresh the QMD search index for this store (optional, no-op when QMD is absent):
165
+ ```bash
166
+ ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>
167
+ ```
168
+ Delivery is the lifecycle event that keeps the search index fresh. `<store-root>` is the
169
+ store that holds this intent (the global store or the project store).
170
+ 9. Disarm the lifecycle gate (auto delivery is finished):
165
171
  ```bash
166
172
  ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_SESSION_ID"])'
167
173
  ```
168
- 9. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
174
+ 10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
169
175
 
170
176
  ## Error Handling
171
177
 
@@ -157,7 +157,18 @@ cd ~/.plastic && git add . && git commit -m "feat: spawn project <slug> from int
157
157
  cd <project> && git add . && git commit -m "feat: initialize project from intent <ID>"
158
158
  ```
159
159
 
160
- ### 10. Announce
160
+ ### 10. Register the project store with QMD (optional)
161
+
162
+ If QMD is installed, register the new project's store as a search collection:
163
+
164
+ ```bash
165
+ ruby ~/.plastic/scripts/qmd-sync register --store ~/.plastic/projects/<slug>/store
166
+ ```
167
+
168
+ `qmd-sync` no-ops when QMD is absent, so run it unconditionally. This adds the
169
+ `plastic-<slug>` collection and indexes it.
170
+
171
+ ### 11. Announce
161
172
 
162
173
  Log in `## Insights` of each founding intent:
163
174
  > "Project `<slug>` created at `<path>`. Tactical mirror: `project-<slug>:1` (autonomous)"
@@ -75,6 +75,7 @@ Capture observations in `## Insights`. When ALL checklist items are checked:
75
75
  3. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
76
76
  4. Update cluster entries to show `_(completed)_`
77
77
  5. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: complete intent <ID> — <name>"`
78
+ 6. Refresh the QMD search index for this store (optional, no-op when QMD is absent): `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>`. Delivery is the lifecycle event that keeps the search index fresh.
78
79
 
79
80
  **This is NOT optional.** An intent with all checklist items done but no Outcome is a broken state. Complete the intent immediately — do not leave it for later.
80
81
 
@@ -100,6 +101,7 @@ Capture observations in `## Insights`. When ALL checklist items are checked:
100
101
  3. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
101
102
  4. Update cluster entries to show `_(completed)_`
102
103
  5. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: complete intent <ID> — <name>"`
104
+ 6. Refresh the QMD search index for this store (optional, no-op when QMD is absent): `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>`. Delivery is the lifecycle event that keeps the search index fresh.
103
105
 
104
106
  **This is NOT optional.** Complete the intent immediately when work is done.
105
107
 
@@ -153,7 +153,20 @@ Auto-commit the config change.
153
153
  Run `/plastic-doctor` and report the result. Resolve any fixable findings before
154
154
  announcing success.
155
155
 
156
- **Step 5: Announce**
156
+ **Step 5: Register stores with QMD (optional)**
157
+
158
+ QMD is an optional search layer. If it is installed, register the Plastic stores so
159
+ they are searchable:
160
+
161
+ ```bash
162
+ ruby ~/.plastic/scripts/qmd-sync detect && ruby ~/.plastic/scripts/qmd-sync register --all
163
+ ```
164
+
165
+ `qmd-sync` no-ops cleanly when QMD is absent, so this is safe to run unconditionally.
166
+ It registers `plastic-global` and every project store from `projects.yml`, then indexes
167
+ them. Report what was registered, or that QMD was not detected and the step was skipped.
168
+
169
+ **Step 6: Announce**
157
170
 
158
171
  > "Plastic installed globally at ~/.plastic/. Health check: [doctor summary].
159
172
  > Create your first intent with `/plastic-creating-intent`."