@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.10
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 +170 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +45 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +47 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
- package/agents/plastic-planner.md +47 -0
- package/agents/plastic-spec-specialist.md +45 -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/hooks/statusline +150 -41
- package/package.json +2 -2
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1219 -621
- 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 +452 -19
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +812 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +57 -0
- package/scripts/lib/qmd_sync.rb +230 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +327 -0
- package/scripts/project-links +287 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +139 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +138 -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 +87 -7
- package/skills/auto/evals/evals.json +255 -0
- package/skills/auto/references/agent-architecture.md +155 -0
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +10 -9
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +99 -82
- package/skills/continuing/evals/evals.json +145 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +82 -36
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +81 -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 +126 -0
- package/skills/dashboard/evals/evals.json +22 -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 +6 -4
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +22 -7
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +45 -0
- package/skills/managing-index/SKILL.md +11 -1
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +10 -2
- package/skills/research/evals/evals.json +22 -0
- 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,57 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "qmd_sync"
|
|
5
|
+
require_relative "power_tools"
|
|
6
|
+
|
|
7
|
+
# QmdHook — decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
|
+
# 66b). Pure and dependency-injected: returns the additionalContext string to
|
|
9
|
+
# emit, or nil to emit nothing. The executable hook wires real deps and prints;
|
|
10
|
+
# this is unit-tested with a fake runner/detector (no real qmd, no network).
|
|
11
|
+
#
|
|
12
|
+
# When qmd is present it still injects scored qmd hits (intent 66), then appends
|
|
13
|
+
# the PowerTools mandate (a MUST obligation per present tool: qmd for finding
|
|
14
|
+
# intents, serena for code navigation) instead of the old soft reminder.
|
|
15
|
+
module QmdHook
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
MIN_PROMPT_LENGTH = 10
|
|
19
|
+
|
|
20
|
+
def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
|
|
21
|
+
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
|
|
22
|
+
serena_detector: nil)
|
|
23
|
+
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
24
|
+
qmd_present = !!detector.call
|
|
25
|
+
serena_present = !!serena_detector.call
|
|
26
|
+
return nil unless qmd_present || serena_present
|
|
27
|
+
|
|
28
|
+
p = prompt.to_s.strip
|
|
29
|
+
# The hit SEARCH is the only expensive step and the only one gated by prompt
|
|
30
|
+
# triviality: skip it for short or bare-"continue" prompts (and when qmd is
|
|
31
|
+
# absent). The mandate itself is always-on for whichever tools are present.
|
|
32
|
+
search_ok = qmd_present && p.length >= MIN_PROMPT_LENGTH && p.downcase != "continue"
|
|
33
|
+
|
|
34
|
+
parts = []
|
|
35
|
+
if search_ok
|
|
36
|
+
collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
37
|
+
hits = QmdSync.search(p, collections: collections, limit: limit,
|
|
38
|
+
min_score: min_score, runner: runner, detector: detector)
|
|
39
|
+
if hits.any?
|
|
40
|
+
parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
|
|
41
|
+
"check before treating this as new work:"
|
|
42
|
+
hits.each do |h|
|
|
43
|
+
loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
44
|
+
pct = (h[:score] * 100).round
|
|
45
|
+
parts << "- [#{pct}%] #{loc} — #{h[:title]}"
|
|
46
|
+
end
|
|
47
|
+
parts << ""
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
52
|
+
serena_detector: -> { serena_present })
|
|
53
|
+
parts << mandate if mandate
|
|
54
|
+
return nil if parts.empty?
|
|
55
|
+
parts.join("\n")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
# Non-blocking reindex for the completion path. QMD has no incremental reindex
|
|
103
|
+
# (`qmd update` is a full rescan, `qmd embed -c` re-embeds the whole collection,
|
|
104
|
+
# ~2 min), so running it inline would block the agent's turn; spawning detached
|
|
105
|
+
# returns immediately. No-op when qmd absent. Spawner/detector are injected so
|
|
106
|
+
# tests assert behavior with no real qmd and no real spawned process.
|
|
107
|
+
def reindex_async(collection:, detector: method(:detect), spawner: method(:default_async_spawner))
|
|
108
|
+
return skip_result unless detector.call
|
|
109
|
+
pid = spawner.call(collection)
|
|
110
|
+
{ ran: true, async: true, pid: pid }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Default spawner: launch `qmd update && qmd embed -c <collection>` detached and
|
|
114
|
+
# non-blocking, discarding output, then detach so it never blocks the turn.
|
|
115
|
+
def default_async_spawner(collection)
|
|
116
|
+
require "shellwords"
|
|
117
|
+
cmd = "qmd update && qmd embed -c #{Shellwords.escape(collection)}"
|
|
118
|
+
pid = Process.spawn(cmd, out: File::NULL, err: File::NULL, pgroup: true)
|
|
119
|
+
Process.detach(pid)
|
|
120
|
+
pid
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Read-only status used by doctor and the session-start report line.
|
|
124
|
+
# Returns a structured hash; never mutates the index.
|
|
125
|
+
def status(plastic_home:, runner: default_runner, detector: method(:detect))
|
|
126
|
+
return { present: false } unless detector.call
|
|
127
|
+
|
|
128
|
+
expected = enumerate_stores(plastic_home: plastic_home).map { |s| s[:collection] }
|
|
129
|
+
listed = list_collections(runner)
|
|
130
|
+
missing = expected - listed
|
|
131
|
+
|
|
132
|
+
{ present: true, expected: expected, registered: listed,
|
|
133
|
+
missing: missing, all_registered: missing.empty? }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Read-only BM25 search over one or more collections. Returns hits sorted by
|
|
137
|
+
# score (desc), filtered by min_score, capped at limit:
|
|
138
|
+
# [{ score: Float, file: String, line: Integer|nil, title: String, snippet: String }, ...]
|
|
139
|
+
# No-ops to [] when qmd is absent or the query is blank. Uses `qmd search --json`
|
|
140
|
+
# (BM25, no embeddings / no model downloads). Pure via the injected runner.
|
|
141
|
+
def search(query, collections:, limit: 3, min_score: 0.5, runner: default_runner, detector: method(:detect))
|
|
142
|
+
return [] unless detector.call
|
|
143
|
+
q = query.to_s.strip
|
|
144
|
+
return [] if q.empty? || Array(collections).empty?
|
|
145
|
+
|
|
146
|
+
args = ["search", q]
|
|
147
|
+
Array(collections).each { |c| args.concat(["-c", c]) }
|
|
148
|
+
args << "--json"
|
|
149
|
+
|
|
150
|
+
out, ok = runner.call(args)
|
|
151
|
+
return [] unless ok && out && !out.strip.empty?
|
|
152
|
+
|
|
153
|
+
parsed = begin
|
|
154
|
+
JSON.parse(out)
|
|
155
|
+
rescue StandardError
|
|
156
|
+
return []
|
|
157
|
+
end
|
|
158
|
+
return [] unless parsed.is_a?(Array)
|
|
159
|
+
|
|
160
|
+
parsed.filter_map do |h|
|
|
161
|
+
next unless h.is_a?(Hash)
|
|
162
|
+
score = h["score"].to_f
|
|
163
|
+
next if score < min_score
|
|
164
|
+
{ score: score, file: h["file"].to_s, line: h["line"],
|
|
165
|
+
title: h["title"].to_s, snippet: h["snippet"].to_s }
|
|
166
|
+
end.sort_by { |h| -h[:score] }.first(limit)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Which collections to search for a given working directory: the matched
|
|
170
|
+
# project's collection plus plastic-global, or just plastic-global when the
|
|
171
|
+
# CWD is not inside any registered project. Project match = CWD equals the
|
|
172
|
+
# registered path or is nested under it.
|
|
173
|
+
def collections_for_cwd(cwd, plastic_home:)
|
|
174
|
+
cwd = File.expand_path(cwd)
|
|
175
|
+
projects = load_projects(plastic_home)
|
|
176
|
+
slug, = projects.find do |_s, info|
|
|
177
|
+
path = info.is_a?(Hash) ? info["path"] : nil
|
|
178
|
+
next false unless path
|
|
179
|
+
root = File.expand_path(path)
|
|
180
|
+
cwd == root || cwd.start_with?(root + File::SEPARATOR)
|
|
181
|
+
end
|
|
182
|
+
slug ? ["plastic-#{slug}", "plastic-global"] : ["plastic-global"]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# --- internals ---
|
|
186
|
+
|
|
187
|
+
def skip_result
|
|
188
|
+
{ ran: false, ok: true, skipped: true }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def list_collections(runner)
|
|
192
|
+
out, ok = runner.call(["collection", "list"])
|
|
193
|
+
return [] unless ok && out
|
|
194
|
+
# qmd prints lines like "plastic-global (qmd://plastic-global/)"; pull the
|
|
195
|
+
# leading collection token off each non-indented line.
|
|
196
|
+
out.lines.filter_map do |line|
|
|
197
|
+
next if line.start_with?(" ", "\t")
|
|
198
|
+
m = line.strip.match(/\A([A-Za-z0-9][\w.-]*)\b/)
|
|
199
|
+
m && m[1]
|
|
200
|
+
end.reject { |t| %w[Collections No].include?(t) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def load_projects(plastic_home)
|
|
204
|
+
path = File.join(plastic_home, "projects.yml")
|
|
205
|
+
return {} unless File.exist?(path)
|
|
206
|
+
data = begin
|
|
207
|
+
YAML.safe_load(File.read(path)) || {}
|
|
208
|
+
rescue StandardError
|
|
209
|
+
{}
|
|
210
|
+
end
|
|
211
|
+
projects = data.is_a?(Hash) ? data["projects"] : nil
|
|
212
|
+
projects.is_a?(Hash) ? projects : {}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def slug_for_store(store_dir, plastic_home:)
|
|
216
|
+
projects = load_projects(plastic_home)
|
|
217
|
+
projects.each do |slug, info|
|
|
218
|
+
path = info.is_a?(Hash) ? info["path"] : nil
|
|
219
|
+
next unless path
|
|
220
|
+
project_root = File.expand_path(path)
|
|
221
|
+
mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
|
|
222
|
+
return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
|
|
223
|
+
end
|
|
224
|
+
# Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
|
|
225
|
+
parts = store_dir.split(File::SEPARATOR)
|
|
226
|
+
idx = parts.rindex("projects")
|
|
227
|
+
return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
|
|
228
|
+
File.basename(File.dirname(store_dir))
|
|
229
|
+
end
|
|
230
|
+
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
|
|