@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2

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 (99) hide show
  1. package/PLASTIC.md +163 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +37 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +37 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
  8. package/agents/plastic-planner.md +37 -0
  9. package/agents/plastic-spec-specialist.md +37 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/package.json +2 -2
  22. package/scripts/dashboard.rb +687 -0
  23. package/scripts/doctor.rb +1054 -628
  24. package/scripts/hook-auto-arm +51 -0
  25. package/scripts/hook-bash-gate +41 -0
  26. package/scripts/hook-code-gate +27 -0
  27. package/scripts/hook-continue +15 -114
  28. package/scripts/hook-create-gate +59 -0
  29. package/scripts/hook-gate-check +47 -32
  30. package/scripts/hook-qmd-search +44 -0
  31. package/scripts/hook-session-start +106 -38
  32. package/scripts/install.rb +91 -529
  33. package/scripts/lib/boot_banner.rb +28 -0
  34. package/scripts/lib/bridge.rb +404 -19
  35. package/scripts/lib/installer_core.rb +807 -0
  36. package/scripts/lib/intent_validator.rb +235 -0
  37. package/scripts/lib/qmd_hook.rb +44 -0
  38. package/scripts/lib/qmd_sync.rb +209 -0
  39. package/scripts/lib/store_provisioning.rb +100 -0
  40. package/scripts/migrate-to-global +1 -1
  41. package/scripts/new-intent +226 -0
  42. package/scripts/provision-project-store +53 -0
  43. package/scripts/qmd-sync +92 -0
  44. package/scripts/select-update-target +93 -0
  45. package/scripts/spawn-preamble +121 -0
  46. package/scripts/uninstall.rb +53 -0
  47. package/scripts/update.rb +164 -0
  48. package/scripts/validate-intent +54 -0
  49. package/scripts/versions.rb +141 -0
  50. package/skills/_active-intent-gate.md +1 -1
  51. package/skills/add-project-store/SKILL.md +54 -0
  52. package/skills/auto/SKILL.md +77 -7
  53. package/skills/auto/evals/evals.json +207 -0
  54. package/skills/auto/references/agent-architecture.md +135 -0
  55. package/skills/brainstorming/SKILL.md +9 -9
  56. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  57. package/skills/continuing/SKILL.md +92 -82
  58. package/skills/continuing/evals/evals.json +136 -0
  59. package/skills/continuing/references/context-management.md +32 -0
  60. package/skills/creating-intent/SKILL.md +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. package/bin/install.js +0 -29
@@ -0,0 +1,235 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "date"
6
+ require "time"
7
+
8
+ # IntentValidator — the single source of truth for "is an intent born complete?"
9
+ # (intent 60).
10
+ #
11
+ # An intent is born complete when its frontmatter carries every required field
12
+ # and its `sources` and `chain` are well-formed arrays of Folgezettel id references
13
+ # (bare ids like `1a2`, or cross-store refs like `global:1a2`; integer ids are coerced).
14
+ # This module is the only definition of that contract; the `validate-intent` CLI,
15
+ # the doctor diagnostics, and the creating-intent skill all consult it so the
16
+ # definition never drifts across copies.
17
+ #
18
+ # Pure and dependency-injected: `validate` accepts an injectable `plastic_home`
19
+ # for house-style parity, parses with a rescue-to-safe-default reader, uses no
20
+ # `eval`, and performs no file writes and no global-constant injection.
21
+ module IntentValidator
22
+ module_function
23
+
24
+ # Must match Doctor::REQUIRED_FRONTMATTER_FIELDS (scripts/doctor.rb).
25
+ REQUIRED_FIELDS = %w[id intent sources chain created author tags].freeze
26
+
27
+ # Fields whose value must be a well-formed array of valid id strings.
28
+ ARRAY_ID_FIELDS = %w[sources chain].freeze
29
+
30
+ # Sanctioned top-level intent sections, in order (intent 60b). The only
31
+ # sanctioned `###` subsection is `### Decisions`, which is OPTIONAL (added after
32
+ # brainstorming) and therefore never flagged as missing. This is the single
33
+ # definition shared by the create gate, the validate-intent CLI, and doctor.
34
+ SANCTIONED_SECTIONS = ["## Intent", "## Context", "## Outcome", "## Insights", "## Links"].freeze
35
+
36
+ # Folgezettel id form: digits then an optional lowercase-letter/digit suffix
37
+ # (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
38
+ ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
39
+
40
+ # True iff `value` is a String matching the Folgezettel id form.
41
+ def valid_id?(value)
42
+ value.to_s.match?(ID_PATTERN)
43
+ end
44
+
45
+ # Read a file's YAML frontmatter, returning the parsed Hash (or {} when the
46
+ # frontmatter block is empty), or nil when there is no parseable frontmatter.
47
+ # A copy of Doctor#parse_frontmatter so `created:` dates do not crash.
48
+ def parse_frontmatter(path)
49
+ return nil unless File.exist?(path)
50
+
51
+ parse_frontmatter_text(File.read(path))
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ # PURE: parse YAML frontmatter from a content STRING (no file IO). Returns the
57
+ # parsed Hash, {} for an empty block, or nil when there is no parseable block.
58
+ def parse_frontmatter_text(content)
59
+ return nil unless content.is_a?(String) && content.start_with?("---")
60
+
61
+ parts = content.split("---", 3)
62
+ return nil if parts.length < 3
63
+
64
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ # PURE: strip the leading YAML frontmatter block from a content STRING,
70
+ # returning the body text (everything after the closing `---`). When there is
71
+ # no frontmatter block, the whole content is the body.
72
+ def body_of(content)
73
+ return "" unless content.is_a?(String)
74
+ return content unless content.start_with?("---")
75
+
76
+ parts = content.split("---", 3)
77
+ parts.length < 3 ? content : parts[2]
78
+ end
79
+
80
+ # PURE: given the intent file body text, return sanctioned-section findings.
81
+ # Flags any unknown top-level `## ` heading and any missing sanctioned section.
82
+ # Ignores `### ` subsections entirely (Decisions is optional and lives under
83
+ # Context). Returns { ok:, missing: [section names], unknown: [heading strings] }.
84
+ def validate_sections(body)
85
+ headings = body.to_s.lines.filter_map do |l|
86
+ s = l.strip
87
+ s if s.start_with?("## ") && !s.start_with?("### ")
88
+ end
89
+ present = headings & SANCTIONED_SECTIONS
90
+ missing = SANCTIONED_SECTIONS - present
91
+ unknown = headings - SANCTIONED_SECTIONS
92
+ { ok: missing.empty? && unknown.empty?, missing: missing, unknown: unknown }
93
+ end
94
+
95
+ # PURE: given a parsed frontmatter Hash (or nil), return
96
+ # { ok: Boolean, missing: [field names], errors: [human strings] }.
97
+ def validate_frontmatter(fm)
98
+ unless fm.is_a?(Hash)
99
+ return { ok: false, missing: REQUIRED_FIELDS.dup, errors: ["no frontmatter found"] }
100
+ end
101
+
102
+ missing = REQUIRED_FIELDS.reject { |f| fm.key?(f) }
103
+ errors = missing.map { |f| "missing required field: #{f}" }
104
+
105
+ ARRAY_ID_FIELDS.each do |key|
106
+ next unless fm.key?(key)
107
+
108
+ value = fm[key]
109
+ unless value.is_a?(Array)
110
+ errors << "#{key} must be an array"
111
+ next
112
+ end
113
+
114
+ value.each do |element|
115
+ errors << "#{key} has invalid id: #{element.inspect}" unless valid_id?(element)
116
+ end
117
+ end
118
+
119
+ { ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
120
+ end
121
+
122
+ # PURE: combine the frontmatter result with section-structure findings for a
123
+ # content STRING. Returns the frontmatter result hash extended with
124
+ # :section_missing, :section_unknown, and folded section errors; :ok is the AND
125
+ # of frontmatter and sections. Lets the create gate validate proposed content
126
+ # (no file on disk) with the same definition as the CLI and doctor.
127
+ def validate_content(content)
128
+ fm_result = validate_frontmatter(parse_frontmatter_text(content))
129
+ sections = validate_sections(body_of(content))
130
+ merge_sections(fm_result, sections)
131
+ end
132
+
133
+ # Fold section findings into a frontmatter result hash (shared by validate and
134
+ # validate_content). Does not mutate the input.
135
+ def merge_sections(fm_result, sections)
136
+ errors = fm_result[:errors].dup
137
+ sections[:unknown].each { |h| errors << "unknown section: #{h}" }
138
+ sections[:missing].each { |s| errors << "missing required section: #{s}" }
139
+ {
140
+ ok: fm_result[:ok] && sections[:ok],
141
+ missing: fm_result[:missing],
142
+ errors: errors,
143
+ section_missing: sections[:missing],
144
+ section_unknown: sections[:unknown],
145
+ }
146
+ end
147
+
148
+ # Resolve an intent directory's primary md file and validate its frontmatter
149
+ # AND its sanctioned section structure. `plastic_home` is accepted for
150
+ # house-style parity (injectable) even though validation reads the dir directly.
151
+ def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
152
+ md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
153
+ content = File.exist?(md_path) ? File.read(md_path) : nil
154
+ validate_content(content)
155
+ end
156
+
157
+ # PURE: cross-intent graph-shape invariants (intent 68). These need visibility
158
+ # over the whole intent set, so they live apart from the single-file born-complete
159
+ # helpers above (which must not drift). No file IO: the caller builds `nodes`.
160
+ #
161
+ # `nodes` is a Hash { id(String) => { sources: [ids], chain: [ids] } } for every
162
+ # intent in ONE store's id space. Returns { i1: [...], i3: [...], i4: [...] },
163
+ # each an array of human-readable finding strings.
164
+ #
165
+ # I2 (no false symmetry) is INTENTIONALLY not computed: a relational `chain` entry
166
+ # with no reciprocal `sources` is valid and must never be flagged.
167
+ def validate_graph(nodes)
168
+ nodes = normalize_nodes(nodes)
169
+ { i1: graph_i1(nodes), i3: graph_i3(nodes), i4: graph_i4(nodes) }
170
+ end
171
+
172
+ # Coerce node arrays to deduped String id lists; tolerate missing keys.
173
+ def normalize_nodes(nodes)
174
+ return {} unless nodes.is_a?(Hash)
175
+
176
+ nodes.each_with_object({}) do |(id, edges), acc|
177
+ edges = {} unless edges.is_a?(Hash)
178
+ acc[id.to_s] = {
179
+ sources: Array(edges[:sources] || edges["sources"]).map(&:to_s).uniq,
180
+ chain: Array(edges[:chain] || edges["chain"]).map(&:to_s).uniq,
181
+ }
182
+ end
183
+ end
184
+
185
+ # An id is a cross-store reference (out of this store's scope) when it carries a
186
+ # `<store>:` prefix, mirroring how `valid_id?` accepts the prefix. Such refs are
187
+ # resolved outside this node set, so they are never danglers here.
188
+ def cross_store_ref?(id)
189
+ id.to_s.include?(":")
190
+ end
191
+
192
+ # I1 (formative reciprocity): for every B and every `s` in B.sources that resolves
193
+ # in this store, B must appear in s.chain. A `s` that does not resolve is an I4
194
+ # dangler, not an I1 violation, so it is skipped here.
195
+ def graph_i1(nodes)
196
+ findings = []
197
+ nodes.each do |b_id, edges|
198
+ edges[:sources].each do |s|
199
+ next if cross_store_ref?(s)
200
+ next unless nodes.key?(s)
201
+
202
+ findings << "#{b_id}.sources lists #{s} but #{s}.chain is missing #{b_id}" unless nodes[s][:chain].include?(b_id)
203
+ end
204
+ end
205
+ findings
206
+ end
207
+
208
+ # I3 (per-node disjoint): X.sources and X.chain must not overlap.
209
+ def graph_i3(nodes)
210
+ findings = []
211
+ nodes.each do |x_id, edges|
212
+ (edges[:sources] & edges[:chain]).each do |overlap|
213
+ findings << "#{x_id} lists #{overlap} in BOTH sources and chain"
214
+ end
215
+ end
216
+ findings
217
+ end
218
+
219
+ # I4 (no danglers): every bare (same-store) id in any sources/chain must resolve
220
+ # to a node. Cross-store `<store>:<id>` refs resolve elsewhere and are not flagged.
221
+ def graph_i4(nodes)
222
+ findings = []
223
+ nodes.each do |id, edges|
224
+ %i[sources chain].each do |field|
225
+ edges[field].each do |ref|
226
+ next if cross_store_ref?(ref)
227
+ next if nodes.key?(ref)
228
+
229
+ findings << "#{id}.#{field} references #{ref} which resolves to no intent"
230
+ end
231
+ end
232
+ end
233
+ findings
234
+ end
235
+ end
@@ -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
@@ -0,0 +1,209 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "json"
6
+
7
+ # QmdSync — the single place Plastic talks to QMD (intent 45a).
8
+ #
9
+ # Plastic computes only the Plastic-specific inputs (which store directory maps to
10
+ # which `plastic-`prefixed collection, derived from projects.yml, plus a short
11
+ # context description). The actual indexing is delegated to the `qmd` CLI; this
12
+ # module never reimplements QMD's logic. QMD is optional: every public entry
13
+ # no-ops cleanly when `qmd` is not on PATH.
14
+ #
15
+ # Pure and dependency-injected: all shelling-out goes through an injected
16
+ # `runner` callable, so the whole module is unit-testable with no real binary,
17
+ # no network, and no model downloads. The default runner shells out to `qmd`.
18
+ module QmdSync
19
+ module_function
20
+
21
+ # A runner is `->(args_array) { [stdout_string, success_boolean] }`.
22
+ # The default invokes the real `qmd` binary.
23
+ def default_runner
24
+ lambda do |args|
25
+ require "open3"
26
+ out, _err, status = Open3.capture3("qmd", *args)
27
+ [out, status.success?]
28
+ end
29
+ end
30
+
31
+ # True when `qmd` is resolvable on PATH. The probe is injectable so tests do
32
+ # not depend on the host having qmd installed.
33
+ def detect(path_probe: method(:which_qmd))
34
+ !!path_probe.call
35
+ end
36
+
37
+ def which_qmd
38
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
39
+ candidate = File.join(dir, "qmd")
40
+ File.file?(candidate) && File.executable?(candidate)
41
+ end
42
+ end
43
+
44
+ # Collection name for a store directory.
45
+ # global store (<plastic_home>/store) -> "plastic-global"
46
+ # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
47
+ # Slug is resolved from projects.yml by matching the project path; falls back
48
+ # to the directory's parent name when no registry match exists.
49
+ def collection_name(store_dir, plastic_home:)
50
+ store_dir = File.expand_path(store_dir)
51
+ global_store = File.expand_path(File.join(plastic_home, "store"))
52
+ return "plastic-global" if store_dir == global_store
53
+
54
+ slug = slug_for_store(store_dir, plastic_home: plastic_home)
55
+ "plastic-#{slug}"
56
+ end
57
+
58
+ # Every store Plastic knows about: the global store plus each registered
59
+ # project store. Returns [{collection:, dir:}, ...].
60
+ def enumerate_stores(plastic_home:)
61
+ stores = [{
62
+ collection: "plastic-global",
63
+ dir: File.expand_path(File.join(plastic_home, "store")),
64
+ }]
65
+
66
+ projects = load_projects(plastic_home)
67
+ projects.each do |slug, info|
68
+ path = info.is_a?(Hash) ? info["path"] : nil
69
+ next unless path
70
+ project_store = File.join(File.expand_path(path), "store")
71
+ # Project stores live under ~/.plastic/projects/<slug>/store as the mirror;
72
+ # registry `path` is the project code dir, so the tactical store is the
73
+ # plastic_home projects mirror.
74
+ mirror_store = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
75
+ dir = Dir.exist?(mirror_store) ? mirror_store : project_store
76
+ stores << { collection: "plastic-#{slug}", dir: dir }
77
+ end
78
+ stores
79
+ end
80
+
81
+ # Register a store directory as a collection. Idempotent: re-running is safe.
82
+ # `qmd collection add` exits non-zero when the collection already exists, so we
83
+ # check the collection list first and treat an existing collection as success.
84
+ # No-op when qmd is absent. (`qmd context add` attaches a summary to a document
85
+ # path, not a collection-level description, so it is not used here.)
86
+ def register(collection:, dir:, runner: default_runner, detector: method(:detect))
87
+ return skip_result unless detector.call
88
+ return { ran: true, ok: true, output: "exists" } if list_collections(runner).include?(collection)
89
+ out, ok = runner.call(["collection", "add", dir, "--name", collection])
90
+ { ran: true, ok: ok, output: out.to_s.strip }
91
+ end
92
+
93
+ # Re-index a single collection: refresh the corpus then its embeddings.
94
+ # Scoped embed (-c) keeps delivery-time reindex fast. No-op when qmd absent.
95
+ def reindex(collection:, runner: default_runner, detector: method(:detect))
96
+ return skip_result unless detector.call
97
+ _o1, ok1 = runner.call(["update"])
98
+ _o2, ok2 = runner.call(["embed", "-c", collection])
99
+ { ran: true, ok: (ok1 && ok2) }
100
+ end
101
+
102
+ # Read-only status used by doctor and the session-start report line.
103
+ # Returns a structured hash; never mutates the index.
104
+ def status(plastic_home:, runner: default_runner, detector: method(:detect))
105
+ return { present: false } unless detector.call
106
+
107
+ expected = enumerate_stores(plastic_home: plastic_home).map { |s| s[:collection] }
108
+ listed = list_collections(runner)
109
+ missing = expected - listed
110
+
111
+ { present: true, expected: expected, registered: listed,
112
+ missing: missing, all_registered: missing.empty? }
113
+ end
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
+
164
+ # --- internals ---
165
+
166
+ def skip_result
167
+ { ran: false, ok: true, skipped: true }
168
+ end
169
+
170
+ def list_collections(runner)
171
+ out, ok = runner.call(["collection", "list"])
172
+ return [] unless ok && out
173
+ # qmd prints lines like "plastic-global (qmd://plastic-global/)"; pull the
174
+ # leading collection token off each non-indented line.
175
+ out.lines.filter_map do |line|
176
+ next if line.start_with?(" ", "\t")
177
+ m = line.strip.match(/\A([A-Za-z0-9][\w.-]*)\b/)
178
+ m && m[1]
179
+ end.reject { |t| %w[Collections No].include?(t) }
180
+ end
181
+
182
+ def load_projects(plastic_home)
183
+ path = File.join(plastic_home, "projects.yml")
184
+ return {} unless File.exist?(path)
185
+ data = begin
186
+ YAML.safe_load(File.read(path)) || {}
187
+ rescue StandardError
188
+ {}
189
+ end
190
+ projects = data.is_a?(Hash) ? data["projects"] : nil
191
+ projects.is_a?(Hash) ? projects : {}
192
+ end
193
+
194
+ def slug_for_store(store_dir, plastic_home:)
195
+ projects = load_projects(plastic_home)
196
+ projects.each do |slug, info|
197
+ path = info.is_a?(Hash) ? info["path"] : nil
198
+ next unless path
199
+ project_root = File.expand_path(path)
200
+ mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
201
+ return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
202
+ end
203
+ # Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
204
+ parts = store_dir.split(File::SEPARATOR)
205
+ idx = parts.rindex("projects")
206
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
207
+ File.basename(File.dirname(store_dir))
208
+ end
209
+ end
@@ -0,0 +1,100 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ # StoreProvisioning — the single source of truth for creating a registered
8
+ # project's intent store (intent 61).
9
+ #
10
+ # A project can be registered in `projects.yml` yet have no store on disk. This
11
+ # module deterministically and idempotently provisions that store at
12
+ # `~/.plastic/projects/{slug}/store`: it makes the store directory, then writes,
13
+ # only if missing, `.gitkeep`, `INDEX.md` (from templates/index.md), and
14
+ # `project.yml` (from templates/project.yml). Re-running never clobbers existing
15
+ # files. The logic was migrated here from the orphaned
16
+ # `InstallerCore#bootstrap_project_store` so there is one definition that the
17
+ # `provision-project-store` CLI, the `plastic-add-project-store` skill, the
18
+ # doctor fix hint, and the project skills all consult.
19
+ #
20
+ # Pure filesystem and dependency-injected: `provision` accepts an injectable
21
+ # `plastic_home` and `package_root` (so templates resolve from a known root and
22
+ # tests stay hermetic), uses no `eval`, performs no qmd mutation, no `system`
23
+ # or `spawn`, no network, and no global-constant injection. An unknown or
24
+ # unregistered slug creates nothing and returns an error result.
25
+ module StoreProvisioning
26
+ module_function
27
+
28
+ # Repo root resolved from this file's location (scripts/lib/), so templates
29
+ # resolve at package_root/templates/...; tests can inject a fake package_root.
30
+ PACKAGE_ROOT = File.expand_path("../..", __dir__)
31
+
32
+ # Provision the store for a registered project.
33
+ #
34
+ # Returns a result hash mirroring IntentValidator.validate's hash style:
35
+ # { ok: true, store_dir: "...", created: [paths written this run] }
36
+ # { ok: false, error: "..." } (nothing is created)
37
+ def provision(slug, plastic_home: File.join(Dir.home, ".plastic"),
38
+ package_root: PACKAGE_ROOT)
39
+ unless registered?(slug, plastic_home)
40
+ return {
41
+ ok: false,
42
+ error: "project '#{slug}' is not registered in projects.yml; " \
43
+ "provisioning requires the project to already be registered",
44
+ }
45
+ end
46
+
47
+ project_dir = File.join(plastic_home, "projects", slug)
48
+ store_dir = File.join(project_dir, "store")
49
+ FileUtils.mkdir_p(store_dir)
50
+
51
+ created = []
52
+ created << write_if_missing(File.join(store_dir, ".gitkeep"), "")
53
+
54
+ index_template = File.join(package_root, "templates", "index.md")
55
+ if File.exist?(index_template)
56
+ created << write_if_missing(File.join(project_dir, "INDEX.md"),
57
+ File.read(index_template))
58
+ end
59
+
60
+ project_template = File.join(package_root, "templates", "project.yml")
61
+ if File.exist?(project_template)
62
+ created << write_if_missing(File.join(project_dir, "project.yml"),
63
+ File.read(project_template))
64
+ end
65
+
66
+ { ok: true, store_dir: store_dir, created: created.compact }
67
+ end
68
+
69
+ # True iff `slug` is a key under the `projects` mapping in projects.yml.
70
+ # Reads with a rescue-to-safe-default reader so a malformed file never raises.
71
+ def registered?(slug, plastic_home)
72
+ projects = load_projects(plastic_home)
73
+ projects.is_a?(Hash) && projects.key?(slug)
74
+ end
75
+
76
+ # Parse projects.yml -> the `projects` Hash, or {} on any error/absence.
77
+ # Mirrors QmdSync#load_projects (scripts/lib/qmd_sync.rb).
78
+ def load_projects(plastic_home)
79
+ path = File.join(plastic_home, "projects.yml")
80
+ return {} unless File.exist?(path)
81
+
82
+ data = begin
83
+ YAML.safe_load(File.read(path)) || {}
84
+ rescue StandardError
85
+ {}
86
+ end
87
+ projects = data.is_a?(Hash) ? data["projects"] : nil
88
+ projects.is_a?(Hash) ? projects : {}
89
+ end
90
+
91
+ # Write `content` to `path` only when `path` does not exist. Returns the path
92
+ # when a file was written, or nil when the file already existed (so callers
93
+ # can build a `created` list). Copied from InstallerCore#write_if_missing.
94
+ def write_if_missing(path, content)
95
+ return nil if File.exist?(path)
96
+
97
+ File.write(path, content)
98
+ path
99
+ end
100
+ end
@@ -17,7 +17,7 @@ GLOBAL_ROOT="$HOME/.plastic"
17
17
  LOCAL_PLASTIC="$PROJECT_PATH/.plastic"
18
18
 
19
19
  if [ ! -d "$GLOBAL_ROOT" ]; then
20
- echo "Error: Global Plastic not installed. Run /plastic:install first." >&2
20
+ echo "Error: Global Plastic not installed. Run /plastic-install first." >&2
21
21
  exit 1
22
22
  fi
23
23