@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
package/scripts/qmd-sync
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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 reindex --store <dir> --async # same, detached/non-blocking
|
|
17
|
+
# qmd-sync status [--format json] # read-only status
|
|
18
|
+
# qmd-sync search "<terms>" [--store <dir>] [--limit N] [--min-score F]
|
|
19
|
+
# # ranked store search; scope by --store or CWD
|
|
20
|
+
#
|
|
21
|
+
# --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
22
|
+
|
|
23
|
+
require "json"
|
|
24
|
+
require_relative "lib/qmd_sync"
|
|
25
|
+
|
|
26
|
+
def plastic_home(args)
|
|
27
|
+
if (i = args.index("--home")) && args[i + 1]
|
|
28
|
+
File.expand_path(args[i + 1])
|
|
29
|
+
else
|
|
30
|
+
File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def opt(args, name)
|
|
35
|
+
(i = args.index(name)) && args[i + 1]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
verb = ARGV.shift
|
|
39
|
+
home = plastic_home(ARGV)
|
|
40
|
+
|
|
41
|
+
unless QmdSync.detect
|
|
42
|
+
case verb
|
|
43
|
+
when "detect"
|
|
44
|
+
warn "QMD not detected on PATH."
|
|
45
|
+
exit 1
|
|
46
|
+
else
|
|
47
|
+
puts "QMD not detected; skipped (#{verb})."
|
|
48
|
+
exit 0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
case verb
|
|
53
|
+
when "detect"
|
|
54
|
+
puts "QMD detected."
|
|
55
|
+
exit 0
|
|
56
|
+
|
|
57
|
+
when "register"
|
|
58
|
+
stores =
|
|
59
|
+
if ARGV.include?("--all")
|
|
60
|
+
QmdSync.enumerate_stores(plastic_home: home)
|
|
61
|
+
elsif (dir = opt(ARGV, "--store"))
|
|
62
|
+
[{ collection: QmdSync.collection_name(dir, plastic_home: home), dir: File.expand_path(dir) }]
|
|
63
|
+
else
|
|
64
|
+
warn "register: pass --store <dir> or --all"
|
|
65
|
+
exit 2
|
|
66
|
+
end
|
|
67
|
+
stores.each do |s|
|
|
68
|
+
next unless Dir.exist?(s[:dir])
|
|
69
|
+
res = QmdSync.register(collection: s[:collection], dir: s[:dir])
|
|
70
|
+
puts "registered #{s[:collection]} -> #{s[:dir]} (#{res[:ok] ? "ok" : "warn"})"
|
|
71
|
+
end
|
|
72
|
+
exit 0
|
|
73
|
+
|
|
74
|
+
when "reindex"
|
|
75
|
+
dir = opt(ARGV, "--store") or (warn("reindex: pass --store <dir>"); exit 2)
|
|
76
|
+
collection = QmdSync.collection_name(dir, plastic_home: home)
|
|
77
|
+
if ARGV.include?("--async")
|
|
78
|
+
QmdSync.reindex_async(collection: collection)
|
|
79
|
+
puts "reindex (async) #{collection} (started)"
|
|
80
|
+
else
|
|
81
|
+
res = QmdSync.reindex(collection: collection)
|
|
82
|
+
puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
|
|
83
|
+
end
|
|
84
|
+
exit 0
|
|
85
|
+
|
|
86
|
+
when "status"
|
|
87
|
+
st = QmdSync.status(plastic_home: home)
|
|
88
|
+
if opt(ARGV, "--format") == "json"
|
|
89
|
+
puts JSON.generate(st)
|
|
90
|
+
else
|
|
91
|
+
puts "QMD present: #{st[:present]}"
|
|
92
|
+
puts "registered: #{Array(st[:registered]).join(", ")}"
|
|
93
|
+
puts "missing: #{Array(st[:missing]).join(", ")}" unless Array(st[:missing]).empty?
|
|
94
|
+
end
|
|
95
|
+
exit 0
|
|
96
|
+
|
|
97
|
+
when "search"
|
|
98
|
+
# The query is the first bareword that is NOT the value of a known value-taking
|
|
99
|
+
# flag. Walk ARGV, skipping each such flag and the token right after it, then
|
|
100
|
+
# take the first remaining token that does not start with "--".
|
|
101
|
+
value_flags = %w[--store --limit --min-score --home]
|
|
102
|
+
query = nil
|
|
103
|
+
i = 0
|
|
104
|
+
while i < ARGV.length
|
|
105
|
+
tok = ARGV[i]
|
|
106
|
+
if value_flags.include?(tok)
|
|
107
|
+
i += 2
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
unless tok.start_with?("--")
|
|
111
|
+
query = tok
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
i += 1
|
|
115
|
+
end
|
|
116
|
+
collections =
|
|
117
|
+
if (dir = opt(ARGV, "--store"))
|
|
118
|
+
[QmdSync.collection_name(dir, plastic_home: home), "plastic-global"].uniq
|
|
119
|
+
else
|
|
120
|
+
QmdSync.collections_for_cwd(Dir.pwd, plastic_home: home)
|
|
121
|
+
end
|
|
122
|
+
limit = (opt(ARGV, "--limit") || "5").to_i
|
|
123
|
+
min_score = (opt(ARGV, "--min-score") || "0.5").to_f
|
|
124
|
+
hits = QmdSync.search(query, collections: collections, limit: limit, min_score: min_score)
|
|
125
|
+
if hits.empty?
|
|
126
|
+
puts "no qmd hits"
|
|
127
|
+
else
|
|
128
|
+
hits.each do |h|
|
|
129
|
+
pct = (h[:score] * 100).round
|
|
130
|
+
path = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
131
|
+
puts "[#{pct}%] #{path} - #{h[:title]}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
exit 0
|
|
135
|
+
|
|
136
|
+
else
|
|
137
|
+
warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status|search."
|
|
138
|
+
exit 2
|
|
139
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
|
|
6
|
+
# global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
|
|
7
|
+
# one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
|
|
8
|
+
# in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
|
|
9
|
+
# relocation map (relocation wins over coincidental id reuse). Emits a
|
|
10
|
+
# before/after audit, then writes minimal style-preserving frontmatter.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
14
|
+
#
|
|
15
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
|
|
16
|
+
# lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
|
|
17
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
18
|
+
|
|
19
|
+
require "yaml"
|
|
20
|
+
require "date"
|
|
21
|
+
require "time"
|
|
22
|
+
require "fileutils"
|
|
23
|
+
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/frontmatter_writer"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
class RebuildGraph
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 49 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"
|
|
34
|
+
|
|
35
|
+
KIND_LABELS = {
|
|
36
|
+
dedupe: "Dedupes",
|
|
37
|
+
i3: "I3 resolutions (kept in sources, dropped from chain)",
|
|
38
|
+
repoint: "Cross-store repoints",
|
|
39
|
+
collapse: "Cross-store collapses (to bare same-store id)",
|
|
40
|
+
drop: "Dropped dead refs",
|
|
41
|
+
i1_backlink: "I1 backlinks added",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
47
|
+
@plastic_home = plastic_home
|
|
48
|
+
@dry_run = dry_run
|
|
49
|
+
|
|
50
|
+
# A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
|
|
51
|
+
# to run --dry-run to review the plan). When no explicit --audit-path is given,
|
|
52
|
+
# a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
|
|
53
|
+
# real-run audit untouched. An explicit --audit-path is always honored verbatim
|
|
54
|
+
# (it is the caller's responsibility, and tests inject it).
|
|
55
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
56
|
+
@audit_path =
|
|
57
|
+
if audit_path
|
|
58
|
+
audit_path
|
|
59
|
+
elsif dry_run
|
|
60
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
61
|
+
else
|
|
62
|
+
canonical
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
67
|
+
|
|
68
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
69
|
+
# `root` is the dir holding INDEX.md; `store` is the intents dir.
|
|
70
|
+
def stores
|
|
71
|
+
list = []
|
|
72
|
+
global_store = File.join(plastic_home, "store")
|
|
73
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
74
|
+
index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
|
|
75
|
+
|
|
76
|
+
%w[plastic knowdb].each do |slug|
|
|
77
|
+
root = File.join(plastic_home, "projects", slug)
|
|
78
|
+
store = File.join(root, "store")
|
|
79
|
+
next unless File.directory?(store)
|
|
80
|
+
|
|
81
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
82
|
+
index: File.join(root, "INDEX.md") }
|
|
83
|
+
end
|
|
84
|
+
list
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { id => { sources:, chain:, path: } } for one store.
|
|
88
|
+
def load_nodes(store_dir)
|
|
89
|
+
nodes = {}
|
|
90
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
91
|
+
dir = File.join(store_dir, entry)
|
|
92
|
+
next unless File.directory?(dir)
|
|
93
|
+
|
|
94
|
+
md = File.join(dir, "#{entry}.md")
|
|
95
|
+
next unless File.exist?(md)
|
|
96
|
+
|
|
97
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
98
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
99
|
+
|
|
100
|
+
nodes[fm["id"].to_s] = {
|
|
101
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
102
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
103
|
+
path: md,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
nodes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
store_list = stores
|
|
111
|
+
nodes_by_store = {}
|
|
112
|
+
index_texts = {}
|
|
113
|
+
store_index = {}
|
|
114
|
+
|
|
115
|
+
store_list.each do |s|
|
|
116
|
+
nodes_by_store[s[:key]] = load_nodes(s[:store])
|
|
117
|
+
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
118
|
+
store_index[s[:key]] = nodes_by_store[s[:key]].keys
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
122
|
+
|
|
123
|
+
results = {}
|
|
124
|
+
store_list.each do |s|
|
|
125
|
+
key = s[:key]
|
|
126
|
+
input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
|
|
127
|
+
results[key] = GraphRebuild.rebuild_store(
|
|
128
|
+
input,
|
|
129
|
+
referer_store: key,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
write_back(store_list, nodes_by_store, results) unless dry_run
|
|
136
|
+
emit_audit(store_list, nodes_by_store, results)
|
|
137
|
+
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Write changed frontmatter back via the minimal style-preserving writer.
|
|
142
|
+
def write_back(store_list, nodes_by_store, results)
|
|
143
|
+
store_list.each do |s|
|
|
144
|
+
key = s[:key]
|
|
145
|
+
new_nodes = results[key][:nodes]
|
|
146
|
+
nodes_by_store[key].each do |id, original|
|
|
147
|
+
rebuilt = new_nodes[id]
|
|
148
|
+
next if rebuilt.nil?
|
|
149
|
+
next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]
|
|
150
|
+
|
|
151
|
+
content = File.read(original[:path])
|
|
152
|
+
updated = FrontmatterWriter.rewrite_arrays(content,
|
|
153
|
+
sources: rebuilt[:sources],
|
|
154
|
+
chain: rebuilt[:chain])
|
|
155
|
+
File.write(original[:path], updated) if updated != content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Render the audit and write it (always, even in dry-run, so the human reviews
|
|
161
|
+
# the dry-run plan). Returns the rendered string.
|
|
162
|
+
def emit_audit(store_list, _nodes_by_store, results)
|
|
163
|
+
text = render_audit(store_list, results)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
165
|
+
File.write(audit_path, text)
|
|
166
|
+
text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# PURE-ish formatter (string from results). Per-store, grouped by kind.
|
|
170
|
+
def render_audit(store_list, results)
|
|
171
|
+
total = store_list.sum { |s| results[s[:key]][:changes].size }
|
|
172
|
+
lines = []
|
|
173
|
+
lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
176
|
+
lines << ""
|
|
177
|
+
lines << "Total changes across all stores: #{total}"
|
|
178
|
+
lines << ""
|
|
179
|
+
|
|
180
|
+
store_list.each do |s|
|
|
181
|
+
key = s[:key]
|
|
182
|
+
changes = results[key][:changes]
|
|
183
|
+
lines << "## #{key}"
|
|
184
|
+
lines << ""
|
|
185
|
+
if changes.empty?
|
|
186
|
+
lines << "No changes."
|
|
187
|
+
lines << ""
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
KIND_ORDER.each do |kind|
|
|
192
|
+
group = changes.select { |c| c[:kind] == kind }
|
|
193
|
+
next if group.empty?
|
|
194
|
+
|
|
195
|
+
lines << "### #{KIND_LABELS[kind]} (#{group.size})"
|
|
196
|
+
group.each { |c| lines << "- #{format_change(c)}" }
|
|
197
|
+
lines << ""
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
lines.join("\n") + "\n"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_change(c)
|
|
205
|
+
case c[:kind]
|
|
206
|
+
when :dedupe
|
|
207
|
+
"#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
|
|
208
|
+
"chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
|
|
209
|
+
when :i3
|
|
210
|
+
"#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
|
|
211
|
+
when :repoint
|
|
212
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
|
|
213
|
+
when :collapse
|
|
214
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
|
|
215
|
+
when :drop
|
|
216
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
|
|
217
|
+
when :i1_backlink
|
|
218
|
+
"#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
|
|
219
|
+
else
|
|
220
|
+
c.inspect
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if $PROGRAM_NAME == __FILE__
|
|
226
|
+
home = RebuildGraph::DEFAULT_HOME
|
|
227
|
+
dry = false
|
|
228
|
+
audit = nil
|
|
229
|
+
i = 0
|
|
230
|
+
while i < ARGV.length
|
|
231
|
+
case ARGV[i]
|
|
232
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
233
|
+
when "--dry-run" then dry = true; i += 1
|
|
234
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
235
|
+
else i += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
240
|
+
results = tool.run
|
|
241
|
+
total = results.values.sum { |r| r[:changes].size }
|
|
242
|
+
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
243
|
+
puts "Audit: #{tool.audit_path}"
|
|
244
|
+
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,138 @@
|
|
|
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 primary output is valid lifecycle artifacts; you close with a structured report about them."
|
|
33
|
+
|
|
34
|
+
# Verbatim completion-report contract (intent 74). Kept as one constant so the
|
|
35
|
+
# contract doc (skills/auto/references/agent-report-contract.md), the role prompts,
|
|
36
|
+
# and the test assert against the exact same string. SINGLE SOURCE OF TRUTH for the
|
|
37
|
+
# report wording: the work output is lifecycle artifacts, the final message is a
|
|
38
|
+
# structured report about them. Both are required and they do not contradict.
|
|
39
|
+
REPORT_CONTRACT =
|
|
40
|
+
"Before you finish, END your turn with a structured completion report as your " \
|
|
41
|
+
"FINAL MESSAGE (your return value), not a side-channel file. Do not go idle or " \
|
|
42
|
+
"finish silently. The report carries a common envelope: role, intent id, stage, " \
|
|
43
|
+
"status (delivered or blocked), artifacts written, verification or tests run, " \
|
|
44
|
+
"checklist deltas, deviations from spec, and blockers or handoff notes; plus a " \
|
|
45
|
+
"role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
|
|
46
|
+
"(for example the planner explains the plan back to the orchestrator). See " \
|
|
47
|
+
"skills/auto/references/agent-report-contract.md for the per-role format."
|
|
48
|
+
|
|
49
|
+
def parse_args(argv)
|
|
50
|
+
role = nil
|
|
51
|
+
step = nil
|
|
52
|
+
positional = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < argv.length
|
|
55
|
+
case argv[i]
|
|
56
|
+
when "--role"
|
|
57
|
+
role = argv[i + 1]
|
|
58
|
+
i += 2
|
|
59
|
+
when "--step"
|
|
60
|
+
step = argv[i + 1]
|
|
61
|
+
i += 2
|
|
62
|
+
else
|
|
63
|
+
positional << argv[i]
|
|
64
|
+
i += 1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
[positional.first, role, step]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Read the intent file frontmatter via the same parser the validator uses, so the
|
|
71
|
+
# id/intent we report match what the rest of Plastic sees. Returns a Hash (possibly
|
|
72
|
+
# empty) — never raises.
|
|
73
|
+
def frontmatter_for(intent_dir)
|
|
74
|
+
ifile = Bridge.intent_file(intent_dir)
|
|
75
|
+
return {} unless File.exist?(ifile)
|
|
76
|
+
|
|
77
|
+
content = File.read(ifile)
|
|
78
|
+
return {} unless content.start_with?("---")
|
|
79
|
+
|
|
80
|
+
parts = content.split("---", 3)
|
|
81
|
+
return {} if parts.length < 3
|
|
82
|
+
|
|
83
|
+
require "yaml"
|
|
84
|
+
require "date"
|
|
85
|
+
require "time"
|
|
86
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
87
|
+
rescue StandardError
|
|
88
|
+
{}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Current stage label. Prefer the last non-empty line of savepoint.md (the ledger
|
|
92
|
+
# already encodes the furthest-reached milestone). Fall back to the file-derived
|
|
93
|
+
# stage when there is no ledger.
|
|
94
|
+
STAGE_LABELS = {
|
|
95
|
+
"what" => "What", "why" => "Why", "how" => "How",
|
|
96
|
+
"exec" => "Exec", "done" => "Done"
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
def current_stage(intent_dir)
|
|
100
|
+
ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
|
|
101
|
+
if File.exist?(ledger)
|
|
102
|
+
last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
|
|
103
|
+
return last if last
|
|
104
|
+
end
|
|
105
|
+
STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
intent_dir_arg, role, step = parse_args(ARGV)
|
|
109
|
+
|
|
110
|
+
if intent_dir_arg.nil? || intent_dir_arg.empty?
|
|
111
|
+
warn "usage: spawn-preamble <intent_dir> [--role ROLE] [--step STEP]"
|
|
112
|
+
exit 2
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
intent_dir = File.expand_path(intent_dir_arg)
|
|
116
|
+
|
|
117
|
+
fm = frontmatter_for(intent_dir)
|
|
118
|
+
id = fm["id"].to_s.strip
|
|
119
|
+
intent_name = fm["intent"].to_s.strip
|
|
120
|
+
id = "(unknown)" if id.empty?
|
|
121
|
+
intent_name = "(unknown)" if intent_name.empty?
|
|
122
|
+
|
|
123
|
+
stage = current_stage(intent_dir)
|
|
124
|
+
cycle = role || step || stage
|
|
125
|
+
|
|
126
|
+
lines = []
|
|
127
|
+
lines << "=== Plastic spawn preamble ==="
|
|
128
|
+
lines << "Store: #{intent_dir}"
|
|
129
|
+
lines << "Active intent: #{id} - #{intent_name}"
|
|
130
|
+
lines << "Current stage: #{stage}"
|
|
131
|
+
lines << "Cycle step / role: #{cycle}"
|
|
132
|
+
lines << ""
|
|
133
|
+
lines << HONOR_INSTRUCTION
|
|
134
|
+
lines << ""
|
|
135
|
+
lines << REPORT_CONTRACT
|
|
136
|
+
lines << "=== end preamble ==="
|
|
137
|
+
|
|
138
|
+
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__
|