@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.
Files changed (115) hide show
  1. package/PLASTIC.md +170 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +45 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +47 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
  8. package/agents/plastic-planner.md +47 -0
  9. package/agents/plastic-spec-specialist.md +45 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/hooks/statusline +150 -41
  22. package/package.json +2 -2
  23. package/scripts/agent-report +142 -0
  24. package/scripts/dashboard.rb +687 -0
  25. package/scripts/doctor.rb +1219 -621
  26. package/scripts/hook-auto-arm +51 -0
  27. package/scripts/hook-bash-gate +41 -0
  28. package/scripts/hook-code-gate +27 -0
  29. package/scripts/hook-continue +15 -114
  30. package/scripts/hook-create-gate +59 -0
  31. package/scripts/hook-gate-check +47 -32
  32. package/scripts/hook-qmd-search +44 -0
  33. package/scripts/hook-session-start +106 -38
  34. package/scripts/install.rb +91 -529
  35. package/scripts/lib/boot_banner.rb +28 -0
  36. package/scripts/lib/bridge.rb +452 -19
  37. package/scripts/lib/frontmatter_writer.rb +130 -0
  38. package/scripts/lib/graph_rebuild.rb +328 -0
  39. package/scripts/lib/installer_core.rb +812 -0
  40. package/scripts/lib/intent_validator.rb +235 -0
  41. package/scripts/lib/links_projection.rb +160 -0
  42. package/scripts/lib/links_section.rb +207 -0
  43. package/scripts/lib/power_tools.rb +76 -0
  44. package/scripts/lib/qmd_hook.rb +57 -0
  45. package/scripts/lib/qmd_sync.rb +230 -0
  46. package/scripts/lib/store_provisioning.rb +100 -0
  47. package/scripts/migrate-to-global +1 -1
  48. package/scripts/new-intent +327 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/provision-project-store +53 -0
  51. package/scripts/qmd-sync +139 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/select-update-target +93 -0
  54. package/scripts/spawn-preamble +138 -0
  55. package/scripts/uninstall.rb +53 -0
  56. package/scripts/update.rb +164 -0
  57. package/scripts/validate-intent +54 -0
  58. package/scripts/versions.rb +141 -0
  59. package/skills/_active-intent-gate.md +1 -1
  60. package/skills/add-project-store/SKILL.md +54 -0
  61. package/skills/auto/SKILL.md +87 -7
  62. package/skills/auto/evals/evals.json +255 -0
  63. package/skills/auto/references/agent-architecture.md +155 -0
  64. package/skills/auto/references/agent-report-contract.md +86 -0
  65. package/skills/brainstorming/SKILL.md +10 -9
  66. package/skills/brainstorming/evals/evals.json +22 -0
  67. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  68. package/skills/continuing/SKILL.md +99 -82
  69. package/skills/continuing/evals/evals.json +145 -0
  70. package/skills/continuing/references/context-management.md +32 -0
  71. package/skills/creating-intent/SKILL.md +82 -36
  72. package/skills/creating-intent/evals/evals.json +72 -0
  73. package/skills/creating-intent/references/lifecycle.md +81 -0
  74. package/skills/creating-intent/references/wikilinks.md +8 -0
  75. package/skills/creating-project/SKILL.md +40 -8
  76. package/skills/creating-project/references/hubs-projects.md +55 -0
  77. package/skills/dashboard/SKILL.md +126 -0
  78. package/skills/dashboard/evals/evals.json +22 -0
  79. package/skills/dashboard/templates/dashboard-global.md +31 -0
  80. package/skills/dashboard/templates/dashboard-project.md +40 -0
  81. package/skills/doctor/SKILL.md +51 -4
  82. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  83. package/skills/doctor/report.md +4 -0
  84. package/skills/evaluating-skills/SKILL.md +140 -0
  85. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  86. package/skills/evaluating-skills/evals/evals.json +75 -0
  87. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  88. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  89. package/skills/executing-plan/SKILL.md +5 -3
  90. package/skills/install/SKILL.md +69 -8
  91. package/skills/intent-curator/SKILL.md +6 -4
  92. package/skills/intent-curator/evals/evals.json +22 -0
  93. package/skills/linking-intents/SKILL.md +22 -7
  94. package/skills/linking-intents/evals/evals.json +22 -0
  95. package/skills/linking-intents/references/zettelkasten.md +45 -0
  96. package/skills/managing-index/SKILL.md +11 -1
  97. package/skills/managing-index/evals/evals.json +22 -0
  98. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  99. package/skills/releasing/SKILL.md +80 -23
  100. package/skills/releasing/references/deprecations.md +60 -0
  101. package/skills/research/SKILL.md +10 -2
  102. package/skills/research/evals/evals.json +22 -0
  103. package/skills/savepoint/SKILL.md +46 -37
  104. package/skills/savepoint/references/context-management.md +32 -0
  105. package/skills/uninstall/SKILL.md +39 -28
  106. package/skills/update/SKILL.md +41 -44
  107. package/skills/versions/SKILL.md +65 -0
  108. package/skills/writing-instructions/SKILL.md +159 -0
  109. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  110. package/skills/writing-plans/SKILL.md +5 -5
  111. package/templates/agents.md +7 -7
  112. package/templates/outcome.md +13 -0
  113. package/templates/savepoint.md +14 -13
  114. package/templates/spec.md +25 -0
  115. package/bin/install.js +0 -29
@@ -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__