@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31
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 +134 -469
- package/README.md +95 -58
- package/agents/future-intent-researcher.md +1 -1
- package/agents/intent-curator.md +1 -1
- 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/gate-check +3 -1
- package/hooks/hooks.json +25 -4
- package/hooks/statusline +50 -10
- package/package.json +2 -2
- package/scripts/dashboard.rb +680 -0
- package/scripts/doctor.rb +1227 -0
- 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-gate-check +30 -34
- package/scripts/hook-session-start +121 -31
- package/scripts/install.rb +91 -487
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +358 -10
- package/scripts/lib/installer_core.rb +747 -0
- package/scripts/lib/intent_validator.rb +90 -0
- package/scripts/lib/qmd_sync.rb +159 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +92 -0
- package/scripts/select-update-target +93 -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 +26 -0
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +69 -9
- package/skills/auto/evals/evals.json +92 -0
- package/skills/auto/references/agent-architecture.md +60 -0
- package/skills/brainstorming/SKILL.md +143 -0
- package/skills/brainstorming-grill-me/SKILL.md +5 -5
- 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 +38 -9
- package/skills/creating-intent/references/lifecycle.md +74 -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 +159 -0
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +100 -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 +5 -1
- package/skills/linking-intents/references/zettelkasten.md +33 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/releasing/SKILL.md +119 -18
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +114 -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 -36
- 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 +183 -0
- package/templates/agents.md +16 -0
- package/templates/outcome.md +13 -0
- package/templates/project.yml +5 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
# Folgezettel id form: digits then an optional lowercase-letter/digit suffix
|
|
31
|
+
# (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
|
|
32
|
+
ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
|
|
33
|
+
|
|
34
|
+
# True iff `value` is a String matching the Folgezettel id form.
|
|
35
|
+
def valid_id?(value)
|
|
36
|
+
value.to_s.match?(ID_PATTERN)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read a file's YAML frontmatter, returning the parsed Hash (or {} when the
|
|
40
|
+
# frontmatter block is empty), or nil when there is no parseable frontmatter.
|
|
41
|
+
# A copy of Doctor#parse_frontmatter so `created:` dates do not crash.
|
|
42
|
+
def parse_frontmatter(path)
|
|
43
|
+
return nil unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
content = File.read(path)
|
|
46
|
+
return nil unless content.start_with?("---")
|
|
47
|
+
|
|
48
|
+
parts = content.split("---", 3)
|
|
49
|
+
return nil if parts.length < 3
|
|
50
|
+
|
|
51
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# PURE: given a parsed frontmatter Hash (or nil), return
|
|
57
|
+
# { ok: Boolean, missing: [field names], errors: [human strings] }.
|
|
58
|
+
def validate_frontmatter(fm)
|
|
59
|
+
unless fm.is_a?(Hash)
|
|
60
|
+
return { ok: false, missing: REQUIRED_FIELDS.dup, errors: ["no frontmatter found"] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
missing = REQUIRED_FIELDS.reject { |f| fm.key?(f) }
|
|
64
|
+
errors = missing.map { |f| "missing required field: #{f}" }
|
|
65
|
+
|
|
66
|
+
ARRAY_ID_FIELDS.each do |key|
|
|
67
|
+
next unless fm.key?(key)
|
|
68
|
+
|
|
69
|
+
value = fm[key]
|
|
70
|
+
unless value.is_a?(Array)
|
|
71
|
+
errors << "#{key} must be an array"
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value.each do |element|
|
|
76
|
+
errors << "#{key} has invalid id: #{element.inspect}" unless valid_id?(element)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
{ ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Resolve an intent directory's primary md file and validate its frontmatter.
|
|
84
|
+
# `plastic_home` is accepted for house-style parity (injectable) even though
|
|
85
|
+
# validation reads the intent dir directly.
|
|
86
|
+
def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
|
|
87
|
+
md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
|
|
88
|
+
validate_frontmatter(parse_frontmatter(md_path))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -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,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
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# provision-project-store — deterministic CLI over StoreProvisioning (intent 61).
|
|
6
|
+
#
|
|
7
|
+
# Creates a registered project's store at ~/.plastic/projects/{slug}/store: it
|
|
8
|
+
# makes the store directory, then writes, only if missing, .gitkeep, INDEX.md,
|
|
9
|
+
# and project.yml. It is pure filesystem and idempotent, so re-running never
|
|
10
|
+
# clobbers existing files and performs no qmd mutation. The project must already
|
|
11
|
+
# be registered in projects.yml; an unregistered slug exits non-zero and creates
|
|
12
|
+
# nothing.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# provision-project-store <slug> [--home <path>]
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 (store provisioned), 1 (unregistered slug / error on stderr),
|
|
18
|
+
# 2 (usage). --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
19
|
+
|
|
20
|
+
require_relative "lib/store_provisioning"
|
|
21
|
+
|
|
22
|
+
def plastic_home(args)
|
|
23
|
+
if (i = args.index("--home")) && args[i + 1]
|
|
24
|
+
File.expand_path(args[i + 1])
|
|
25
|
+
else
|
|
26
|
+
File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
home = plastic_home(ARGV)
|
|
31
|
+
|
|
32
|
+
# First positional arg, skipping any flag and the value that follows --home.
|
|
33
|
+
home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
|
|
34
|
+
slug_index = ARGV.each_index.find do |idx|
|
|
35
|
+
arg = ARGV[idx]
|
|
36
|
+
!arg.start_with?("--") && idx != home_value_index
|
|
37
|
+
end
|
|
38
|
+
slug = slug_index && ARGV[slug_index]
|
|
39
|
+
|
|
40
|
+
if slug.nil?
|
|
41
|
+
warn "usage: provision-project-store <slug> [--home <path>]"
|
|
42
|
+
exit 2
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result = StoreProvisioning.provision(slug, plastic_home: home)
|
|
46
|
+
|
|
47
|
+
if result[:ok]
|
|
48
|
+
puts "OK: #{result[:store_dir]}"
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
warn result[:error]
|
|
53
|
+
exit 1
|
package/scripts/qmd-sync
ADDED
|
@@ -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
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# plastic-script-version: 1.0.0
|
|
3
|
+
# Channel-aware update target selector.
|
|
4
|
+
#
|
|
5
|
+
# Usage: npm view @zalom/plastic dist-tags --json | select-update-target <current-version>
|
|
6
|
+
#
|
|
7
|
+
# Prints the best update target to stdout, or nothing if already up to date.
|
|
8
|
+
#
|
|
9
|
+
# Rule: target = the highest version, among the dist-tags reachable from the
|
|
10
|
+
# installed channel, that is strictly greater than the installed version (semver).
|
|
11
|
+
# alpha installed -> reachable: alpha, beta, latest
|
|
12
|
+
# beta installed -> reachable: beta, latest (never downgrade to alpha)
|
|
13
|
+
# stable installed -> reachable: latest (stable only)
|
|
14
|
+
# A prerelease (1.1.0-alpha.1) is LOWER than its release (1.1.0); the `latest`
|
|
15
|
+
# placeholder (0.0.1) is naturally excluded as lower than any 1.0.0-alpha.N.
|
|
16
|
+
|
|
17
|
+
require "json"
|
|
18
|
+
|
|
19
|
+
# Parse "MAJOR.MINOR.PATCH[-prerelease]" into a comparable structure.
|
|
20
|
+
# Returns nil for anything that isn't a clean semver string.
|
|
21
|
+
def parse(version)
|
|
22
|
+
m = /\A(\d+)\.(\d+)\.(\d+)(?:-(.+))?\z/.match(version.to_s.strip)
|
|
23
|
+
return nil unless m
|
|
24
|
+
{ maj: m[1].to_i, min: m[2].to_i, pat: m[3].to_i,
|
|
25
|
+
pre: m[4] ? m[4].split(".") : nil }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Semver precedence (§11). Returns -1, 0, or 1.
|
|
29
|
+
def compare(a, b)
|
|
30
|
+
%i[maj min pat].each do |k|
|
|
31
|
+
return a[k] <=> b[k] unless a[k] == b[k]
|
|
32
|
+
end
|
|
33
|
+
# A version with no prerelease is greater than one with a prerelease.
|
|
34
|
+
return 0 if a[:pre].nil? && b[:pre].nil?
|
|
35
|
+
return 1 if a[:pre].nil?
|
|
36
|
+
return -1 if b[:pre].nil?
|
|
37
|
+
[a[:pre].length, b[:pre].length].max.times do |i|
|
|
38
|
+
x = a[:pre][i]
|
|
39
|
+
y = b[:pre][i]
|
|
40
|
+
return -1 if x.nil?
|
|
41
|
+
return 1 if y.nil?
|
|
42
|
+
xn = x.match?(/\A\d+\z/)
|
|
43
|
+
yn = y.match?(/\A\d+\z/)
|
|
44
|
+
if xn && yn
|
|
45
|
+
return x.to_i <=> y.to_i unless x.to_i == y.to_i
|
|
46
|
+
elsif xn
|
|
47
|
+
return -1 # numeric identifiers rank lower than alphanumeric
|
|
48
|
+
elsif yn
|
|
49
|
+
return 1
|
|
50
|
+
elsif x != y
|
|
51
|
+
return x <=> y
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def channel(version)
|
|
58
|
+
return "alpha" if version.include?("-alpha")
|
|
59
|
+
return "beta" if version.include?("-beta")
|
|
60
|
+
"stable"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
REACH = {
|
|
64
|
+
"alpha" => %w[alpha beta latest],
|
|
65
|
+
"beta" => %w[beta latest],
|
|
66
|
+
"stable" => %w[latest],
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
current = ARGV[0]
|
|
70
|
+
cur = parse(current)
|
|
71
|
+
exit 0 unless cur
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
tags = JSON.parse($stdin.read)
|
|
75
|
+
rescue JSON::ParserError, StandardError
|
|
76
|
+
exit 0
|
|
77
|
+
end
|
|
78
|
+
exit 0 unless tags.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
best = nil
|
|
81
|
+
best_parsed = nil
|
|
82
|
+
REACH[channel(current)].each do |tag|
|
|
83
|
+
raw = tags[tag]
|
|
84
|
+
next unless raw
|
|
85
|
+
parsed = parse(raw)
|
|
86
|
+
next unless parsed
|
|
87
|
+
if compare(parsed, cur) > 0 && (best_parsed.nil? || compare(parsed, best_parsed) > 0)
|
|
88
|
+
best = raw
|
|
89
|
+
best_parsed = parsed
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
print best if best
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Plastic — `uninstall` verb. Runs via `npx @zalom/plastic uninstall` (bin/plastic.js) or directly.
|
|
6
|
+
# Usage: ruby scripts/uninstall.rb [--claude|--codex|--hermes|--all] [--help]
|
|
7
|
+
#
|
|
8
|
+
# Manifest-driven removal of installed files + legacy-plugin migration. Leaves ~/.plastic/
|
|
9
|
+
# (intent store, config, and the versions.json ledger) in place — the ledger is permanent.
|
|
10
|
+
|
|
11
|
+
require_relative "lib/installer_core"
|
|
12
|
+
|
|
13
|
+
class Uninstall < InstallerCore
|
|
14
|
+
def cli(argv = ARGV)
|
|
15
|
+
if argv.include?("--help") || argv.include?("-h")
|
|
16
|
+
show_help
|
|
17
|
+
return 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
selected = agent_keys_from(argv)
|
|
21
|
+
selected = ["claude"] if selected.empty?
|
|
22
|
+
|
|
23
|
+
puts "\n\u{1f9e0} Plastic v#{version} — uninstall\n\n"
|
|
24
|
+
handle_uninstall(selected)
|
|
25
|
+
0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def show_help
|
|
31
|
+
puts <<~HELP
|
|
32
|
+
|
|
33
|
+
plastic uninstall — remove Plastic from an agent
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
npx @zalom/plastic uninstall [options]
|
|
37
|
+
|
|
38
|
+
Agent options:
|
|
39
|
+
--claude Uninstall from Claude Code (default)
|
|
40
|
+
--codex Uninstall from Codex CLI
|
|
41
|
+
--hermes Uninstall from Hermes
|
|
42
|
+
--all Uninstall from all supported agents
|
|
43
|
+
|
|
44
|
+
Other options:
|
|
45
|
+
-h, --help Show this help
|
|
46
|
+
|
|
47
|
+
Leaves your intent store, config, and version ledger (~/.plastic/) in place.
|
|
48
|
+
|
|
49
|
+
HELP
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
exit(Uninstall.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__
|