@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,226 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# new-intent (intent 60b) - the one-call scaffolding contract.
|
|
6
|
+
#
|
|
7
|
+
# A single invocation scaffolds a COMPLETE intent: it allocates the Folgezettel
|
|
8
|
+
# id (root vs branch by --parent), creates the directory tree (<id>--<slug>/ plus
|
|
9
|
+
# actions/ and resources/), renders the born-complete intent file from
|
|
10
|
+
# templates/intent.md, writes sentinel placeholder lifecycle files (spec.md,
|
|
11
|
+
# plan.md, checklist.md, outcome.md, each carrying <!-- plastic:placeholder -->
|
|
12
|
+
# as its first line), wires reciprocal file links, and self-validates with
|
|
13
|
+
# IntentValidator (exit non-zero if not born complete).
|
|
14
|
+
#
|
|
15
|
+
# It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
|
|
16
|
+
# responsibilities (see plastic-creating-intent).
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
|
|
20
|
+
# [--parent <id>] [--author <name>] [--sources id,id] \
|
|
21
|
+
# [--tags tag,tag] [--templates <dir>]
|
|
22
|
+
#
|
|
23
|
+
# Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
|
|
24
|
+
|
|
25
|
+
require "fileutils"
|
|
26
|
+
require "date"
|
|
27
|
+
require_relative "lib/bridge"
|
|
28
|
+
require_relative "lib/intent_validator"
|
|
29
|
+
|
|
30
|
+
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
31
|
+
|
|
32
|
+
def parse_args(argv)
|
|
33
|
+
opts = {
|
|
34
|
+
store: nil, intent: nil, slug: nil, parent: nil,
|
|
35
|
+
author: "claude-code", sources: [], tags: [], templates: nil
|
|
36
|
+
}
|
|
37
|
+
i = 0
|
|
38
|
+
while i < argv.length
|
|
39
|
+
arg = argv[i]
|
|
40
|
+
case arg
|
|
41
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
42
|
+
when "--intent" then opts[:intent] = argv[i += 1]
|
|
43
|
+
when "--slug" then opts[:slug] = argv[i += 1]
|
|
44
|
+
when "--parent" then opts[:parent] = argv[i += 1]
|
|
45
|
+
when "--author" then opts[:author] = argv[i += 1]
|
|
46
|
+
when "--sources" then opts[:sources] = split_list(argv[i += 1])
|
|
47
|
+
when "--tags" then opts[:tags] = split_list(argv[i += 1])
|
|
48
|
+
when "--templates" then opts[:templates] = argv[i += 1]
|
|
49
|
+
else
|
|
50
|
+
abort "new-intent: unknown argument #{arg.inspect}"
|
|
51
|
+
end
|
|
52
|
+
i += 1
|
|
53
|
+
end
|
|
54
|
+
opts
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def split_list(value)
|
|
58
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expand(path)
|
|
62
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Default templates dir: sibling of this script's dir. Works in-repo
|
|
66
|
+
# (<repo>/scripts/new-intent -> <repo>/templates) and installed
|
|
67
|
+
# (~/.plastic/scripts/new-intent -> ~/.plastic/templates when present).
|
|
68
|
+
def default_templates_dir
|
|
69
|
+
File.expand_path("../templates", __dir__)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_tokens(text, tokens)
|
|
73
|
+
tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Add an id to a source intent's frontmatter `chain` array, idempotently (I1
|
|
77
|
+
# reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
|
|
78
|
+
# targeted edit of the `chain:` line only; the body (including `## Links`) is
|
|
79
|
+
# preserved byte-for-byte and no other frontmatter key is touched, so the file
|
|
80
|
+
# stays born-complete. Renders the array in flow style (`["a", "b"]`) to match
|
|
81
|
+
# templates/intent.md. No-op when the id is already present.
|
|
82
|
+
def add_to_chain(file_path, new_id)
|
|
83
|
+
return unless File.exist?(file_path)
|
|
84
|
+
content = File.read(file_path)
|
|
85
|
+
return unless content.start_with?("---")
|
|
86
|
+
|
|
87
|
+
parts = content.split("---", 3)
|
|
88
|
+
return unless parts.length >= 3
|
|
89
|
+
|
|
90
|
+
fm = parts[1]
|
|
91
|
+
chain_line = fm.lines.find { |l| l.match?(/\A\s*chain\s*:/) }
|
|
92
|
+
return unless chain_line
|
|
93
|
+
|
|
94
|
+
existing = fm.match(/\bchain\s*:\s*\[(.*?)\]/m)
|
|
95
|
+
ids =
|
|
96
|
+
if existing
|
|
97
|
+
existing[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
|
|
98
|
+
else
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
return if ids.include?(new_id)
|
|
102
|
+
|
|
103
|
+
ids << new_id
|
|
104
|
+
rendered = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
|
|
105
|
+
new_fm = fm.sub(/^\s*chain\s*:.*$/, rendered)
|
|
106
|
+
File.write(file_path, ["", new_fm, parts[2]].join("---"))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Append a wikilink line under the file's `## Links` section, idempotently.
|
|
110
|
+
def append_link(file_path, link_line)
|
|
111
|
+
return unless File.exist?(file_path)
|
|
112
|
+
content = File.read(file_path)
|
|
113
|
+
return if content.include?(link_line)
|
|
114
|
+
|
|
115
|
+
lines = content.lines
|
|
116
|
+
idx = lines.index { |l| l.strip == "## Links" }
|
|
117
|
+
return unless idx
|
|
118
|
+
|
|
119
|
+
insert_at = lines.length
|
|
120
|
+
((idx + 1)...lines.length).each do |j|
|
|
121
|
+
if lines[j].strip.start_with?("## ")
|
|
122
|
+
insert_at = j
|
|
123
|
+
break
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
lines.insert(insert_at, "#{link_line}\n")
|
|
127
|
+
File.write(file_path, lines.join)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def main(argv)
|
|
131
|
+
opts = parse_args(argv)
|
|
132
|
+
abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
|
|
133
|
+
abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
|
|
134
|
+
abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
|
|
135
|
+
|
|
136
|
+
store = expand(opts[:store])
|
|
137
|
+
abort "new-intent: store dir does not exist: #{store}" unless Dir.exist?(store)
|
|
138
|
+
|
|
139
|
+
templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
|
|
140
|
+
abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
|
|
141
|
+
|
|
142
|
+
# 1. Allocate id via the existing folgezettel-id logic (root vs branch).
|
|
143
|
+
folg = File.expand_path("folgezettel-id", __dir__)
|
|
144
|
+
cmd = [folg, store]
|
|
145
|
+
cmd << opts[:parent] if opts[:parent] && !opts[:parent].empty?
|
|
146
|
+
id = `#{cmd.map { |c| "'#{c}'" }.join(" ")}`.strip
|
|
147
|
+
abort "new-intent: id allocation failed" if id.empty?
|
|
148
|
+
|
|
149
|
+
slug = opts[:slug]
|
|
150
|
+
intent_dir = File.join(store, "#{id}--#{slug}")
|
|
151
|
+
abort "new-intent: #{intent_dir} already exists" if File.exist?(intent_dir)
|
|
152
|
+
|
|
153
|
+
# 2. Create dirs.
|
|
154
|
+
FileUtils.mkdir_p(File.join(intent_dir, "actions"))
|
|
155
|
+
FileUtils.mkdir_p(File.join(intent_dir, "resources"))
|
|
156
|
+
|
|
157
|
+
# 3. Render the born-complete intent file from templates/intent.md.
|
|
158
|
+
sources = opts[:sources]
|
|
159
|
+
sources = sources | [opts[:parent]] if opts[:parent] && !opts[:parent].empty? && !sources.include?(opts[:parent])
|
|
160
|
+
sources_str = sources.map { |s| "\"#{s}\"" }.join(", ")
|
|
161
|
+
tags_str = opts[:tags].map { |t| "\"#{t}\"" }.join(", ")
|
|
162
|
+
|
|
163
|
+
intent_template = File.read(File.join(templates, "intent.md"))
|
|
164
|
+
intent_body = render_tokens(intent_template, {
|
|
165
|
+
"ID" => id,
|
|
166
|
+
"INTENT" => opts[:intent],
|
|
167
|
+
"SOURCES" => sources_str,
|
|
168
|
+
"DATE" => Date.today.iso8601,
|
|
169
|
+
"AUTHOR" => opts[:author],
|
|
170
|
+
"TAGS" => tags_str,
|
|
171
|
+
"DESCRIPTION" => opts[:intent],
|
|
172
|
+
})
|
|
173
|
+
intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
|
|
174
|
+
File.write(intent_file, intent_body)
|
|
175
|
+
|
|
176
|
+
# 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
|
|
177
|
+
# `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
|
|
178
|
+
# `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
|
|
179
|
+
# folds in `--parent`). Scope boundary: 68 fixes ONLY the frontmatter `chain`
|
|
180
|
+
# backlink; the `## Links` wikilink projection for the `--sources` path is intent 72.
|
|
181
|
+
sources.each do |src_id|
|
|
182
|
+
next if src_id.nil? || src_id.empty?
|
|
183
|
+
|
|
184
|
+
src_dir = Dir.glob(File.join(store, "#{src_id}--*")).find { |d| File.directory?(d) }
|
|
185
|
+
next unless src_dir
|
|
186
|
+
|
|
187
|
+
src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
|
|
188
|
+
add_to_chain(src_file, id)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 4b. Reciprocal `## Links` wikilinks: forward link to parent + back-reference in
|
|
192
|
+
# the parent (kept parent-only; the `--sources` path's wikilink is intent 72's scope).
|
|
193
|
+
if opts[:parent] && !opts[:parent].empty?
|
|
194
|
+
parent_id = opts[:parent]
|
|
195
|
+
append_link(intent_file, "- [[#{parent_id}]]")
|
|
196
|
+
parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
|
|
197
|
+
if parent_dir
|
|
198
|
+
parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
|
|
199
|
+
append_link(parent_file, "- [[#{id}]]")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
|
|
204
|
+
# line; the rendered template body follows so the file is a usable starting
|
|
205
|
+
# point once an agent deletes the sentinel.
|
|
206
|
+
%w[spec.md plan.md checklist.md outcome.md].each do |name|
|
|
207
|
+
template_path = File.join(templates, name)
|
|
208
|
+
body = File.exist?(template_path) ? File.read(template_path) : ""
|
|
209
|
+
body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
|
|
210
|
+
File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# 6. Self-validate (frontmatter + sanctioned sections).
|
|
214
|
+
result = IntentValidator.validate(intent_dir)
|
|
215
|
+
unless result[:ok]
|
|
216
|
+
warn "new-intent: scaffolded intent is NOT born complete:"
|
|
217
|
+
result[:missing].each { |f| warn " missing field: #{f}" }
|
|
218
|
+
result[:errors].each { |e| warn " #{e}" }
|
|
219
|
+
exit 1
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
puts intent_dir
|
|
223
|
+
exit 0
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -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,121 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# spawn-preamble — deterministic live-state injection for spawned agents
|
|
6
|
+
# (intent 4a1c1, agent harness foundation).
|
|
7
|
+
#
|
|
8
|
+
# Emits a preamble block built ONLY from filesystem state for a single intent
|
|
9
|
+
# directory. It is a PURE function of the intent dir: no network, no randomness,
|
|
10
|
+
# no wall-clock reads. Two runs over the same on-disk state produce byte-identical
|
|
11
|
+
# output. This is the authoritative L2 (live-state) mechanism for harnesses whose
|
|
12
|
+
# spawned sub-agents do not inherit a top-level session event (see
|
|
13
|
+
# docs/reference/harness-adapters.md).
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# spawn-preamble <intent_dir> [--role ROLE] [--step STEP]
|
|
17
|
+
#
|
|
18
|
+
# The preamble reports the active intent (id + intent line from frontmatter), the
|
|
19
|
+
# current lifecycle stage (last savepoint line if present, else derived from which
|
|
20
|
+
# lifecycle files exist), the cycle role/step (from --role/--step, else the
|
|
21
|
+
# derived stage), and an imperative honoring instruction.
|
|
22
|
+
#
|
|
23
|
+
# Exit codes: 0 (preamble emitted), 2 (usage).
|
|
24
|
+
|
|
25
|
+
require_relative "lib/bridge"
|
|
26
|
+
|
|
27
|
+
# Verbatim honoring instruction. Kept as one constant so the contract doc and the
|
|
28
|
+
# test assert against the exact same string.
|
|
29
|
+
HONOR_INSTRUCTION =
|
|
30
|
+
"You are operating inside Plastic. Use it as your operating scaffold. " \
|
|
31
|
+
"Emit VALID lifecycle artifacts; do not hallucinate intents or stages. " \
|
|
32
|
+
"Your output is a deliverable, not a message."
|
|
33
|
+
|
|
34
|
+
def parse_args(argv)
|
|
35
|
+
role = nil
|
|
36
|
+
step = nil
|
|
37
|
+
positional = []
|
|
38
|
+
i = 0
|
|
39
|
+
while i < argv.length
|
|
40
|
+
case argv[i]
|
|
41
|
+
when "--role"
|
|
42
|
+
role = argv[i + 1]
|
|
43
|
+
i += 2
|
|
44
|
+
when "--step"
|
|
45
|
+
step = argv[i + 1]
|
|
46
|
+
i += 2
|
|
47
|
+
else
|
|
48
|
+
positional << argv[i]
|
|
49
|
+
i += 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
[positional.first, role, step]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read the intent file frontmatter via the same parser the validator uses, so the
|
|
56
|
+
# id/intent we report match what the rest of Plastic sees. Returns a Hash (possibly
|
|
57
|
+
# empty) — never raises.
|
|
58
|
+
def frontmatter_for(intent_dir)
|
|
59
|
+
ifile = Bridge.intent_file(intent_dir)
|
|
60
|
+
return {} unless File.exist?(ifile)
|
|
61
|
+
|
|
62
|
+
content = File.read(ifile)
|
|
63
|
+
return {} unless content.start_with?("---")
|
|
64
|
+
|
|
65
|
+
parts = content.split("---", 3)
|
|
66
|
+
return {} if parts.length < 3
|
|
67
|
+
|
|
68
|
+
require "yaml"
|
|
69
|
+
require "date"
|
|
70
|
+
require "time"
|
|
71
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
72
|
+
rescue StandardError
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Current stage label. Prefer the last non-empty line of savepoint.md (the ledger
|
|
77
|
+
# already encodes the furthest-reached milestone). Fall back to the file-derived
|
|
78
|
+
# stage when there is no ledger.
|
|
79
|
+
STAGE_LABELS = {
|
|
80
|
+
"what" => "What", "why" => "Why", "how" => "How",
|
|
81
|
+
"exec" => "Exec", "done" => "Done"
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
def current_stage(intent_dir)
|
|
85
|
+
ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
|
|
86
|
+
if File.exist?(ledger)
|
|
87
|
+
last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
|
|
88
|
+
return last if last
|
|
89
|
+
end
|
|
90
|
+
STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
intent_dir_arg, role, step = parse_args(ARGV)
|
|
94
|
+
|
|
95
|
+
if intent_dir_arg.nil? || intent_dir_arg.empty?
|
|
96
|
+
warn "usage: spawn-preamble <intent_dir> [--role ROLE] [--step STEP]"
|
|
97
|
+
exit 2
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
intent_dir = File.expand_path(intent_dir_arg)
|
|
101
|
+
|
|
102
|
+
fm = frontmatter_for(intent_dir)
|
|
103
|
+
id = fm["id"].to_s.strip
|
|
104
|
+
intent_name = fm["intent"].to_s.strip
|
|
105
|
+
id = "(unknown)" if id.empty?
|
|
106
|
+
intent_name = "(unknown)" if intent_name.empty?
|
|
107
|
+
|
|
108
|
+
stage = current_stage(intent_dir)
|
|
109
|
+
cycle = role || step || stage
|
|
110
|
+
|
|
111
|
+
lines = []
|
|
112
|
+
lines << "=== Plastic spawn preamble ==="
|
|
113
|
+
lines << "Store: #{intent_dir}"
|
|
114
|
+
lines << "Active intent: #{id} - #{intent_name}"
|
|
115
|
+
lines << "Current stage: #{stage}"
|
|
116
|
+
lines << "Cycle step / role: #{cycle}"
|
|
117
|
+
lines << ""
|
|
118
|
+
lines << HONOR_INSTRUCTION
|
|
119
|
+
lines << "=== end preamble ==="
|
|
120
|
+
|
|
121
|
+
puts lines.join("\n")
|
|
@@ -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__
|