@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.
- package/PLASTIC.md +163 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +37 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +37 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
- package/agents/plastic-planner.md +37 -0
- package/agents/plastic-spec-specialist.md +37 -0
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +1 -10
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +12 -0
- package/hooks/create-gate +3 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +52 -0
- package/hooks/qmd-search +8 -0
- package/package.json +2 -2
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1054 -628
- package/scripts/hook-auto-arm +51 -0
- package/scripts/hook-bash-gate +41 -0
- package/scripts/hook-code-gate +27 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-create-gate +59 -0
- package/scripts/hook-gate-check +47 -32
- package/scripts/hook-qmd-search +44 -0
- package/scripts/hook-session-start +106 -38
- package/scripts/install.rb +91 -529
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +404 -19
- package/scripts/lib/installer_core.rb +807 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/qmd_hook.rb +44 -0
- package/scripts/lib/qmd_sync.rb +209 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +226 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +92 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +121 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +164 -0
- package/scripts/validate-intent +54 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +1 -1
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +77 -7
- package/skills/auto/evals/evals.json +207 -0
- package/skills/auto/references/agent-architecture.md +135 -0
- package/skills/brainstorming/SKILL.md +9 -9
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +92 -82
- package/skills/continuing/evals/evals.json +136 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +74 -36
- package/skills/creating-intent/evals/evals.json +56 -0
- package/skills/creating-intent/references/lifecycle.md +76 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +40 -8
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +121 -0
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
- package/skills/doctor/SKILL.md +51 -4
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +4 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +5 -3
- package/skills/install/SKILL.md +69 -8
- package/skills/intent-curator/SKILL.md +3 -3
- package/skills/linking-intents/SKILL.md +16 -7
- package/skills/linking-intents/references/zettelkasten.md +38 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/managing-index/references/zettelkasten-linking.md +1 -1
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +2 -2
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -44
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +5 -5
- package/templates/agents.md +7 -7
- package/templates/outcome.md +13 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- 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
|
|
20
|
+
echo "Error: Global Plastic not installed. Run /plastic-install first." >&2
|
|
21
21
|
exit 1
|
|
22
22
|
fi
|
|
23
23
|
|